From 15bbaa0f98b021bf74d42893923b72ec5490690b Mon Sep 17 00:00:00 2001 From: tm-ruxandra Date: Wed, 13 Nov 2024 11:18:20 -0500 Subject: [PATCH] feat: sync diff (#2414) Description Enhance the basic soilDataDiff system introduced in #2279 to support tracking changes to all fields. The new methods plug into the remoteSoilDataActions methods for generating push input and populate the push input with the diff of the last-synced data from the server. Note: this PR must be reviewed after #2413, as it builds on that changeset. --- .../soilId/actions/remoteSoilDataActions.ts | 23 +- .../soilId/actions/soilDataActionFields.ts | 2 +- .../model/soilId/actions/soilDataDiff.test.ts | 428 +++++++++++++++--- .../src/model/soilId/actions/soilDataDiff.ts | 92 ++++ 4 files changed, 485 insertions(+), 60 deletions(-) diff --git a/dev-client/src/model/soilId/actions/remoteSoilDataActions.ts b/dev-client/src/model/soilId/actions/remoteSoilDataActions.ts index 25bc2a9b1..06865fa8e 100644 --- a/dev-client/src/model/soilId/actions/remoteSoilDataActions.ts +++ b/dev-client/src/model/soilId/actions/remoteSoilDataActions.ts @@ -24,7 +24,12 @@ import { import * as remoteSoilData from 'terraso-client-shared/soilId/soilDataService'; import {SoilData} from 'terraso-client-shared/soilId/soilIdTypes'; -import {getDeletedDepthIntervals} from 'terraso-mobile-client/model/soilId/actions/soilDataDiff'; +import { + getChangedDepthDependentData, + getChangedDepthIntervals, + getChangedSoilDataFields, + getDeletedDepthIntervals, +} from 'terraso-mobile-client/model/soilId/actions/soilDataDiff'; import { getEntityRecord, SyncRecord, @@ -69,13 +74,23 @@ export const unsyncedDataToMutationInputEntry = ( return { siteId, soilData: { - ...soilData, - depthIntervals: soilData.depthIntervals, - depthDependentData: soilData.depthDependentData, + ...getChangedSoilDataFields(soilData, record.lastSyncedData), + depthIntervals: getChangedDepthIntervals( + soilData, + record.lastSyncedData, + ).map(changes => { + return {depthInterval: changes.depthInterval, ...changes.changedFields}; + }), deletedDepthIntervals: getDeletedDepthIntervals( soilData, record.lastSyncedData, ), + depthDependentData: getChangedDepthDependentData( + soilData, + record.lastSyncedData, + ).map(changes => { + return {depthInterval: changes.depthInterval, ...changes.changedFields}; + }), }, }; }; diff --git a/dev-client/src/model/soilId/actions/soilDataActionFields.ts b/dev-client/src/model/soilId/actions/soilDataActionFields.ts index 0c22ef9e3..8dbc09f4c 100644 --- a/dev-client/src/model/soilId/actions/soilDataActionFields.ts +++ b/dev-client/src/model/soilId/actions/soilDataActionFields.ts @@ -51,7 +51,7 @@ export const SOIL_DATA_UPDATE_FIELDS = [ 'waterTableDepthSelect', ] as const satisfies (keyof SoilData)[] & (keyof SoilDataUpdateMutationInput)[]; -export type UpdateField = (typeof SOIL_DATA_UPDATE_FIELDS)[number]; +export type SoilDataUpdateField = (typeof SOIL_DATA_UPDATE_FIELDS)[number]; /** * The soil data depth interval fields which are covered by the depth interval update action. diff --git a/dev-client/src/model/soilId/actions/soilDataDiff.test.ts b/dev-client/src/model/soilId/actions/soilDataDiff.test.ts index b70af140d..198cadef2 100644 --- a/dev-client/src/model/soilId/actions/soilDataDiff.test.ts +++ b/dev-client/src/model/soilId/actions/soilDataDiff.test.ts @@ -15,71 +15,389 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ -import {getDeletedDepthIntervals} from 'terraso-mobile-client/model/soilId/actions/soilDataDiff'; -import {SoilData} from 'terraso-mobile-client/model/soilId/soilIdSlice'; - -describe('getDeletedDepthIntervals', () => { - let curr: SoilData; - let prev: SoilData; - - beforeEach(() => { - curr = { - depthIntervalPreset: 'CUSTOM', - depthIntervals: [], - depthDependentData: [], - }; - prev = { - depthIntervalPreset: 'CUSTOM', - depthIntervals: [], - depthDependentData: [], +import { + DEPTH_DEPENDENT_SOIL_DATA_UPDATE_FIELDS, + DEPTH_INTERVAL_UPDATE_FIELDS, + SOIL_DATA_UPDATE_FIELDS, +} from 'terraso-mobile-client/model/soilId/actions/soilDataActionFields'; +import { + getChangedDepthDependentData, + getChangedDepthDependentFields, + getChangedDepthIntervalFields, + getChangedDepthIntervals, + getChangedSoilDataFields, + getDeletedDepthIntervals, +} from 'terraso-mobile-client/model/soilId/actions/soilDataDiff'; +import { + DepthDependentSoilData, + SoilData, + SoilDataDepthInterval, +} from 'terraso-mobile-client/model/soilId/soilIdSlice'; + +describe('soil data diff', () => { + describe('getChangedSoilDataFields', () => { + const someSoilData = (more?: Partial): SoilData => { + return { + depthIntervalPreset: 'CUSTOM', + depthIntervals: [], + depthDependentData: [], + + bedrock: 1, + crossSlope: 'CONCAVE', + downSlope: 'CONCAVE', + floodingSelect: 'FREQUENT', + grazingSelect: 'CAMEL', + landCoverSelect: 'BARREN', + limeRequirementsSelect: 'HIGH', + slopeAspect: 1, + slopeLandscapePosition: 'ALLUVIAL_FAN', + slopeSteepnessDegree: 1, + slopeSteepnessPercent: 1, + slopeSteepnessSelect: 'FLAT', + soilDepthSelect: 'BETWEEN_50_AND_70_CM', + surfaceCracksSelect: 'DEEP_VERTICAL_CRACKS', + surfaceSaltSelect: 'MOST_OF_SURFACE', + surfaceStoninessSelect: 'BETWEEN_01_AND_3', + waterTableDepthSelect: 'BETWEEN_30_AND_45_CM', + + ...more, + }; }; + + test('returns all fields when no previous record', () => { + let curr = someSoilData(); + + const changed = getChangedSoilDataFields(curr, undefined); + for (const field of SOIL_DATA_UPDATE_FIELDS) { + expect(Object.keys(changed)).toContain(field); + expect(changed[field]).toEqual(curr[field]); + } + }); + + test('returns all changed fields', () => { + let curr = someSoilData(); + let prev: SoilData = { + depthIntervalPreset: 'BLM', + depthDependentData: [], + depthIntervals: [], + }; + + const changed = getChangedSoilDataFields(curr, prev); + for (const field of SOIL_DATA_UPDATE_FIELDS) { + expect(Object.keys(changed)).toContain(field); + expect(changed[field]).toEqual(curr[field]); + } + }); + + test('returns only changed fields', () => { + let curr = someSoilData({soilDepthSelect: 'BETWEEN_50_AND_70_CM'}); + let prev = someSoilData({ + soilDepthSelect: 'GREATER_THAN_20_LESS_THAN_50_CM', + }); + + const changed = getChangedSoilDataFields(curr, prev); + expect(changed).toEqual({soilDepthSelect: 'BETWEEN_50_AND_70_CM'}); + }); }); - test('returns empty when no previous record', () => { - curr.depthIntervals = [ - {label: '', depthInterval: {start: 1, end: 2}}, - {label: '', depthInterval: {start: 2, end: 3}}, - ]; + describe('getDeletedDepthIntervals', () => { + let curr: SoilData; + let prev: SoilData; + + beforeEach(() => { + curr = { + depthIntervalPreset: 'CUSTOM', + depthIntervals: [], + depthDependentData: [], + }; + prev = { + depthIntervalPreset: 'CUSTOM', + depthIntervals: [], + depthDependentData: [], + }; + }); + + test('returns empty when no previous record', () => { + curr.depthIntervals = [ + {label: '', depthInterval: {start: 1, end: 2}}, + {label: '', depthInterval: {start: 2, end: 3}}, + ]; + + const deleted = getDeletedDepthIntervals(curr, undefined); + expect(deleted).toEqual([]); + }); - const deleted = getDeletedDepthIntervals(curr, undefined); - expect(deleted).toEqual([]); + test('returns empty when no deleted records', () => { + curr.depthIntervals = [ + {label: '', depthInterval: {start: 1, end: 2}}, + {label: '', depthInterval: {start: 2, end: 3}}, + ]; + prev.depthIntervals = [ + {label: '', depthInterval: {start: 1, end: 2}}, + {label: '', depthInterval: {start: 2, end: 3}}, + ]; + + const deleted = getDeletedDepthIntervals(curr, undefined); + expect(deleted).toEqual([]); + }); + + test('returns prev depth intervals when deleted', () => { + prev.depthIntervals = [ + {label: '', depthInterval: {start: 1, end: 2}}, + {label: '', depthInterval: {start: 2, end: 3}}, + ]; + + const deleted = getDeletedDepthIntervals(curr, prev); + expect(deleted).toEqual([ + {start: 1, end: 2}, + {start: 2, end: 3}, + ]); + }); + + test('returns only deleted prev intervals', () => { + curr.depthIntervals = [{label: '', depthInterval: {start: 2, end: 3}}]; + prev.depthIntervals = [ + {label: '', depthInterval: {start: 1, end: 2}}, + {label: '', depthInterval: {start: 2, end: 3}}, + ]; + + const deleted = getDeletedDepthIntervals(curr, prev); + expect(deleted).toEqual([{start: 1, end: 2}]); + }); }); - test('returns empty when no deleted records', () => { - curr.depthIntervals = [ - {label: '', depthInterval: {start: 1, end: 2}}, - {label: '', depthInterval: {start: 2, end: 3}}, - ]; - prev.depthIntervals = [ - {label: '', depthInterval: {start: 1, end: 2}}, - {label: '', depthInterval: {start: 2, end: 3}}, - ]; - - const deleted = getDeletedDepthIntervals(curr, undefined); - expect(deleted).toEqual([]); + describe('getChangedDepthIntervals', () => { + let curr: SoilData; + let prev: SoilData; + + beforeEach(() => { + curr = { + depthIntervalPreset: 'CUSTOM', + depthIntervals: [], + depthDependentData: [], + }; + prev = { + depthIntervalPreset: 'CUSTOM', + depthIntervals: [], + depthDependentData: [], + }; + }); + + test('returns all depth intervals when no previous record', () => { + curr.depthIntervals = [ + {label: '', depthInterval: {start: 1, end: 2}}, + {label: '', depthInterval: {start: 2, end: 3}}, + ]; + + const changed = getChangedDepthIntervals(curr, undefined); + expect(changed).toHaveLength(2); + expect(changed[0].depthInterval).toEqual({start: 1, end: 2}); + expect(changed[1].depthInterval).toEqual({start: 2, end: 3}); + }); + + test('returns only changed intervals', () => { + curr.depthIntervals = [ + {label: 'changed', depthInterval: {start: 1, end: 2}}, + {label: 'old', depthInterval: {start: 2, end: 3}}, + {label: 'added', depthInterval: {start: 3, end: 4}}, + ]; + prev.depthIntervals = [ + {label: '', depthInterval: {start: 1, end: 2}}, + {label: 'old', depthInterval: {start: 2, end: 3}}, + {label: 'deleted', depthInterval: {start: 4, end: 5}}, + ]; + + const changed = getChangedDepthIntervals(curr, prev); + expect(changed).toHaveLength(2); + expect(changed[0].depthInterval).toEqual({start: 1, end: 2}); + expect(changed[1].depthInterval).toEqual({start: 3, end: 4}); + }); + + test('returns changed interval fields', () => { + curr.depthIntervals = [ + {label: 'changed', depthInterval: {start: 1, end: 2}}, + ]; + prev.depthIntervals = [{label: '', depthInterval: {start: 1, end: 2}}]; + + const changed = getChangedDepthIntervals(curr, prev); + expect(changed).toHaveLength(1); + expect(changed[0].changedFields).toEqual({label: 'changed'}); + }); }); - test('returns prev depth intervals when deleted', () => { - prev.depthIntervals = [ - {label: '', depthInterval: {start: 1, end: 2}}, - {label: '', depthInterval: {start: 2, end: 3}}, - ]; - - const deleted = getDeletedDepthIntervals(curr, prev); - expect(deleted).toEqual([ - {start: 1, end: 2}, - {start: 2, end: 3}, - ]); + describe('getChangedDepthIntervalFields', () => { + const someSoilDataDi = ( + more?: Partial, + ): SoilDataDepthInterval => { + return { + depthInterval: {start: 1, end: 2}, + label: 'test', + + carbonatesEnabled: false, + electricalConductivityEnabled: false, + phEnabled: false, + sodiumAdsorptionRatioEnabled: false, + soilColorEnabled: false, + soilOrganicCarbonMatterEnabled: false, + soilStructureEnabled: false, + soilTextureEnabled: false, + + ...more, + }; + }; + + test('returns all fields when no previous record', () => { + let curr = someSoilDataDi(); + + const changed = getChangedDepthIntervalFields(curr, undefined); + for (const field of DEPTH_INTERVAL_UPDATE_FIELDS) { + expect(Object.keys(changed)).toContain(field); + expect(changed[field]).toEqual(curr[field]); + } + }); + + test('returns all changed fields', () => { + let curr = someSoilDataDi(); + let prev: SoilDataDepthInterval = { + depthInterval: {start: 1, end: 2}, + label: '', + }; + + const changed = getChangedDepthIntervalFields(curr, prev); + for (const field of DEPTH_INTERVAL_UPDATE_FIELDS) { + expect(Object.keys(changed)).toContain(field); + expect(changed[field]).toEqual(curr[field]); + } + }); + + test('returns only changed fields', () => { + let curr = someSoilDataDi({label: 'a'}); + let prev = someSoilDataDi({label: 'b'}); + + const changed = getChangedDepthIntervalFields(curr, prev); + expect(changed).toEqual({label: 'a'}); + }); }); - test('returns only deleted prev intervals', () => { - curr.depthIntervals = [{label: '', depthInterval: {start: 2, end: 3}}]; - prev.depthIntervals = [ - {label: '', depthInterval: {start: 1, end: 2}}, - {label: '', depthInterval: {start: 2, end: 3}}, - ]; + describe('getChangedDepthDependentData', () => { + let curr: SoilData; + let prev: SoilData; + + beforeEach(() => { + curr = { + depthIntervalPreset: 'CUSTOM', + depthIntervals: [], + depthDependentData: [], + }; + prev = { + depthIntervalPreset: 'CUSTOM', + depthIntervals: [], + depthDependentData: [], + }; + }); + + test('returns all data when no previous record', () => { + curr.depthDependentData = [ + {depthInterval: {start: 1, end: 2}}, + {depthInterval: {start: 2, end: 3}}, + ]; + + const changed = getChangedDepthDependentData(curr, undefined); + expect(changed).toHaveLength(2); + expect(changed[0].depthInterval).toEqual({start: 1, end: 2}); + expect(changed[1].depthInterval).toEqual({start: 2, end: 3}); + }); + + test('returns only changed data', () => { + curr.depthDependentData = [ + {ph: 1, depthInterval: {start: 1, end: 2}}, + {ph: 2, depthInterval: {start: 2, end: 3}}, + {ph: 3, depthInterval: {start: 3, end: 4}}, + ]; + prev.depthDependentData = [ + {ph: 0, depthInterval: {start: 1, end: 2}}, + {ph: 2, depthInterval: {start: 2, end: 3}}, + ]; + + const changed = getChangedDepthDependentData(curr, prev); + expect(changed).toHaveLength(2); + expect(changed[0].depthInterval).toEqual({start: 1, end: 2}); + expect(changed[1].depthInterval).toEqual({start: 3, end: 4}); + }); + + test('returns changed data fields', () => { + curr.depthDependentData = [{ph: 1, depthInterval: {start: 1, end: 2}}]; + prev.depthDependentData = [{ph: 0, depthInterval: {start: 1, end: 2}}]; + + const changed = getChangedDepthDependentData(curr, prev); + expect(changed).toHaveLength(1); + expect(changed[0].changedFields).toEqual({ph: 1}); + }); + }); + + describe('getChangedDepthDependentFields', () => { + const someSoilDataDi = ( + more?: Partial, + ): DepthDependentSoilData => { + return { + depthInterval: {start: 1, end: 2}, + + carbonates: 'NONEFFERVESCENT', + clayPercent: 1, + colorChroma: 0.1, + colorHue: 0.1, + colorPhotoLightingCondition: 'EVEN', + colorPhotoSoilCondition: 'DRY', + colorPhotoUsed: false, + colorValue: 0.1, + conductivity: 0.1, + conductivityTest: 'OTHER', + conductivityUnit: 'DECISIEMENS_METER', + ph: 0.1, + phTestingMethod: 'INDICATOR_SOLUTION', + phTestingSolution: 'OTHER', + rockFragmentVolume: 'VOLUME_0_1', + sodiumAbsorptionRatio: 0.1, + soilOrganicCarbon: 0.1, + soilOrganicCarbonTesting: 'DRY_COMBUSTION', + soilOrganicMatter: 0.1, + soilOrganicMatterTesting: 'DRY_COMBUSTION', + structure: 'ANGULAR_BLOCKY', + texture: 'CLAY', + + ...more, + }; + }; + + test('returns all fields when no previous record', () => { + let curr = someSoilDataDi(); + + const changed = getChangedDepthDependentFields(curr, undefined); + for (const field of DEPTH_DEPENDENT_SOIL_DATA_UPDATE_FIELDS) { + expect(Object.keys(changed)).toContain(field); + expect(changed[field]).toEqual(curr[field]); + } + }); + + test('returns all changed fields', () => { + let curr = someSoilDataDi(); + let prev: DepthDependentSoilData = { + depthInterval: {start: 1, end: 2}, + }; + + const changed = getChangedDepthDependentFields(curr, prev); + for (const field of DEPTH_DEPENDENT_SOIL_DATA_UPDATE_FIELDS) { + expect(Object.keys(changed)).toContain(field); + expect(changed[field]).toEqual(curr[field]); + } + }); + + test('returns only changed fields', () => { + let curr = someSoilDataDi({texture: 'CLAY'}); + let prev = someSoilDataDi({texture: 'CLAY_LOAM'}); - const deleted = getDeletedDepthIntervals(curr, prev); - expect(deleted).toEqual([{start: 1, end: 2}]); + const changed = getChangedDepthDependentFields(curr, prev); + expect(changed).toEqual({texture: 'CLAY'}); + }); }); }); diff --git a/dev-client/src/model/soilId/actions/soilDataDiff.ts b/dev-client/src/model/soilId/actions/soilDataDiff.ts index 690a36ad7..78540b6ed 100644 --- a/dev-client/src/model/soilId/actions/soilDataDiff.ts +++ b/dev-client/src/model/soilId/actions/soilDataDiff.ts @@ -16,12 +16,31 @@ */ import { + DepthDependentSoilData, DepthInterval, SoilData, + SoilDataDepthInterval, } from 'terraso-client-shared/soilId/soilIdTypes'; +import { + DEPTH_DEPENDENT_SOIL_DATA_UPDATE_FIELDS, + DEPTH_INTERVAL_UPDATE_FIELDS, + SOIL_DATA_UPDATE_FIELDS, +} from 'terraso-mobile-client/model/soilId/actions/soilDataActionFields'; import {depthIntervalKey} from 'terraso-mobile-client/model/soilId/soilIdFunctions'; +export const getChangedSoilDataFields = ( + curr: SoilData, + prev?: SoilData, +): Partial => { + return diffFields(SOIL_DATA_UPDATE_FIELDS, curr, prev); +}; + +export type DepthIntervalChanges = { + depthInterval: DepthInterval; + changedFields: Partial; +}; + export const getDeletedDepthIntervals = ( curr: SoilData, prev?: SoilData, @@ -37,3 +56,76 @@ export const getDeletedDepthIntervals = ( .filter(di => !currIntervals.has(depthIntervalKey(di.depthInterval))) .map(di => di.depthInterval); }; + +export const getChangedDepthIntervals = ( + curr: SoilData, + prev?: SoilData, +): DepthIntervalChanges[] => { + const prevIntervals = indexDepthIntervals(prev?.depthIntervals ?? []); + const diffs = curr.depthIntervals.map(di => { + return { + depthInterval: di.depthInterval, + changedFields: getChangedDepthIntervalFields( + di, + prevIntervals[depthIntervalKey(di.depthInterval)], + ), + }; + }); + + return diffs.filter(di => Object.keys(di.changedFields).length > 0); +}; + +export const getChangedDepthIntervalFields = ( + curr: SoilDataDepthInterval, + prev?: SoilDataDepthInterval, +): Partial => { + return diffFields(DEPTH_INTERVAL_UPDATE_FIELDS, curr, prev); +}; + +export const getChangedDepthDependentData = ( + curr: SoilData, + prev?: SoilData, +): DepthIntervalChanges[] => { + const prevData = indexDepthIntervals(prev?.depthDependentData ?? []); + const diffs = curr.depthDependentData.map(dd => { + return { + depthInterval: dd.depthInterval, + changedFields: getChangedDepthDependentFields( + dd, + prevData[depthIntervalKey(dd.depthInterval)], + ), + }; + }); + return diffs.filter(di => Object.keys(di.changedFields).length > 0); +}; + +export const getChangedDepthDependentFields = ( + curr: DepthDependentSoilData, + prev?: DepthDependentSoilData, +): Partial => { + return diffFields(DEPTH_DEPENDENT_SOIL_DATA_UPDATE_FIELDS, curr, prev); +}; + +export const diffFields = ( + fields: F[], + curr: T, + prev?: T, +): Partial => { + let changedFields: (keyof T)[]; + if (!prev) { + changedFields = fields; + } else { + changedFields = fields.filter(field => curr[field] !== prev[field]); + } + + return Object.fromEntries( + changedFields.map(field => [field, curr[field]]), + ) as Partial; +}; + +export const indexDepthIntervals = ( + items: (T & {depthInterval: DepthInterval})[], +): Record => + Object.fromEntries( + items.map(item => [depthIntervalKey(item.depthInterval), item]), + );