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]), + );