Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: soil change tracking #2364

Merged
merged 8 commits into from
Oct 24, 2024
1 change: 1 addition & 0 deletions dev-client/__tests__/snapshot/SlopeScreen-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ test('renders correctly', () => {
slopeSteepnessSelect: 'FLAT',
},
},
soilChanges: {},
projectSettings: {
'1': {
...fromEntries(
Expand Down
9 changes: 7 additions & 2 deletions dev-client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,10 @@ import {SitesScreenContextProvider} from 'terraso-mobile-client/context/SitesScr
import {RootNavigator} from 'terraso-mobile-client/navigation/navigators/RootNavigator';
import {Toasts} from 'terraso-mobile-client/screens/Toasts';
import {createStore} from 'terraso-mobile-client/store';
import {loadPersistedReduxState} from 'terraso-mobile-client/store/persistence';
import {
loadPersistedReduxState,
patchPersistedReduxState,
} from 'terraso-mobile-client/store/persistence';
import {paperTheme, theme} from 'terraso-mobile-client/theme';

enableFreeze(true);
Expand Down Expand Up @@ -87,7 +90,9 @@ LogBox.ignoreLogs([
'In React 18, SSRProvider is not necessary and is a noop. You can remove it from your app.',
]);

const store = createStore(loadPersistedReduxState());
const persistedReduxState = loadPersistedReduxState();
patchPersistedReduxState(persistedReduxState);
const store = createStore(persistedReduxState);

function App(): React.JSX.Element {
const [headerHeight, setHeaderHeight] = useState(0);
Expand Down
4 changes: 4 additions & 0 deletions dev-client/src/components/buttons/SyncButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ import {useCallback} from 'react';

import {Button} from 'native-base';

import {Text} from 'terraso-mobile-client/components/NativeBaseAdapters';
import {RestrictByFlag} from 'terraso-mobile-client/components/RestrictByFlag';
import {selectUnsyncedSiteIds} from 'terraso-mobile-client/model/soilId/soilIdSelectors';
import {fetchSoilDataForUser} from 'terraso-mobile-client/model/soilId/soilIdSlice';
import {useDispatch, useSelector} from 'terraso-mobile-client/store';

Expand All @@ -31,6 +33,7 @@ export const SyncButton = () => {
const currentUserID = useSelector(
state => state.account.currentUser?.data?.id,
);
const unsyncedIds = useSelector(selectUnsyncedSiteIds);

const onSync = useCallback(() => {
if (currentUserID !== undefined) {
Expand All @@ -42,6 +45,7 @@ export const SyncButton = () => {
// TODO-offline: Create string in en.json if we actually want this button for reals
<RestrictByFlag flag="FF_offline">
<Button onPress={onSync}>SYNC: pull</Button>
<Text>({unsyncedIds.length} changed sites)</Text>
</RestrictByFlag>
);
};
16 changes: 15 additions & 1 deletion dev-client/src/model/soilId/soilIdSelectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,26 @@
* along with this program. If not, see https://www.gnu.org/licenses/.
*/

import {SoilIdKey} from 'terraso-client-shared/soilId/soilIdTypes';
import {createSelector} from '@reduxjs/toolkit';

import {SoilData, SoilIdKey} from 'terraso-client-shared/soilId/soilIdTypes';

import {SoilIdEntry} from 'terraso-mobile-client/model/soilId/soilIdSlice';
import {
ChangeRecords,
getUnsyncedRecords,
} from 'terraso-mobile-client/model/sync/sync';
import {AppState} from 'terraso-mobile-client/store';

export const selectSoilIdMatches =
(key: SoilIdKey) =>
(state: AppState): SoilIdEntry | undefined =>
state.soilId.matches[key];

export const selectUnsyncedSites = (state: AppState): ChangeRecords<SoilData> =>
getUnsyncedRecords(state.soilId.soilChanges);

export const selectUnsyncedSiteIds = createSelector(
selectUnsyncedSites,
changes => Object.keys(changes),
);
23 changes: 22 additions & 1 deletion dev-client/src/model/soilId/soilIdSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ import {
soilIdEntryLocationBased,
soilIdKey,
} from 'terraso-mobile-client/model/soilId/soilIdFunctions';
import {
ChangeRecords,
markAllChanged,
markAllSynced,
markChanged,
} from 'terraso-mobile-client/model/sync/sync';

export * from 'terraso-client-shared/soilId/soilIdTypes';
export * from 'terraso-mobile-client/model/soilId/soilIdFunctions';
Expand All @@ -53,14 +59,18 @@ export type MethodRequired<

export type SoilState = {
soilData: Record<string, SoilData | undefined>;
soilChanges: ChangeRecords<SoilData>;

projectSettings: Record<string, ProjectSoilSettings | undefined>;
status: LoadingState;

matches: Record<SoilIdKey, SoilIdEntry>;
};

const initialState: SoilState = {
export const initialState: SoilState = {
soilData: {},
soilChanges: {},

projectSettings: {},
status: 'loading',

Expand All @@ -74,12 +84,19 @@ const soilIdSlice = createSlice({
setSoilData: (state, action: PayloadAction<Record<string, SoilData>>) => {
state.soilData = action.payload;
state.matches = {};

markAllSynced(state.soilChanges, action.payload, Date.now());
},
updateSoilData: (
state,
action: PayloadAction<Record<string, SoilData>>,
) => {
Object.assign(state.soilData, action.payload);
markAllChanged(
state.soilChanges,
Object.keys(action.payload),
Date.now(),
);
},
setProjectSettings: (
state,
Expand All @@ -103,21 +120,25 @@ const soilIdSlice = createSlice({
extraReducers: builder => {
builder.addCase(updateSoilData.fulfilled, (state, action) => {
state.soilData[action.meta.arg.siteId] = action.payload;
markChanged(state.soilChanges, action.meta.arg.siteId, Date.now());
flushDataBasedMatches(state);
});

builder.addCase(updateDepthDependentSoilData.fulfilled, (state, action) => {
state.soilData[action.meta.arg.siteId] = action.payload;
markChanged(state.soilChanges, action.meta.arg.siteId, Date.now());
flushDataBasedMatches(state);
});

builder.addCase(updateSoilDataDepthInterval.fulfilled, (state, action) => {
state.soilData[action.meta.arg.siteId] = action.payload;
markChanged(state.soilChanges, action.meta.arg.siteId, Date.now());
flushDataBasedMatches(state);
});

builder.addCase(deleteSoilDataDepthInterval.fulfilled, (state, action) => {
state.soilData[action.meta.arg.siteId] = action.payload;
markChanged(state.soilChanges, action.meta.arg.siteId, Date.now());
flushDataBasedMatches(state);
});

Expand Down
197 changes: 197 additions & 0 deletions dev-client/src/model/sync/sync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
/*
* Copyright © 2024 Technology Matters
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/

import {
ChangeRecords,
getChanges,
getRevisionId,
getUnsyncedRecords,
isUnsynced,
markChanged,
markSynced,
nextRevisionId,
} from 'terraso-mobile-client/model/sync/sync';

describe('sync', () => {
beforeEach(() => {
jest.useFakeTimers().setSystemTime(new Date('2020-01-01'));
});

describe('getRevisionId', () => {
test('zero for undefined revision', () => {
expect(getRevisionId(undefined)).toEqual(0);
});

test('zero for empty revision', () => {
expect(getRevisionId({})).toEqual(0);
});

test('value for revision', () => {
expect(getRevisionId({revisionId: 123})).toEqual(123);
});
});

describe('nextRevisionId', () => {
test('increments by one', () => {
expect(nextRevisionId(1)).toEqual(2);
});
});

describe('getChanges', () => {
test('returns changes for ids', () => {
const changes = {a: {revisionId: 1}, b: {revisionId: 2}};
expect(getChanges(changes, ['a'])).toEqual({a: {revisionId: 1}});
});

test('returns empty changes when missing', () => {
const changes = {};
expect(getChanges(changes, ['a'])).toEqual({a: {}});
});
});

describe('markChanged', () => {
let changes: ChangeRecords<unknown>;

beforeEach(() => {
changes = {};
});

test('initializes change record if not present', () => {
markChanged(changes, 'a', Date.now());
expect(changes.a).toBeDefined();
});

test('initializes revision id if not present', () => {
changes.a = {};
markChanged(changes, 'a', Date.now());
expect(changes.a.revisionId).toEqual(1);
});

test('increments revision id', () => {
changes.a = {revisionId: 122};
markChanged(changes, 'a', Date.now());
expect(changes.a.revisionId).toEqual(123);
});

test('records modified date', () => {
const at = Date.now();
markChanged(changes, 'a', at);
expect(changes.a.lastModifiedAt).toEqual(at);
});

test('preserves other properties', () => {
changes.a = {
lastSyncedRevisionId: 100,
lastSyncedData: 'data',
lastSyncedAt: 10000,
};
markChanged(changes, 'a', Date.now());
expect(changes.a.lastSyncedRevisionId).toEqual(100);
expect(changes.a.lastSyncedData).toEqual('data');
expect(changes.a.lastSyncedAt).toEqual(10000);
});
});

describe('markSynced', () => {
let changes: ChangeRecords<unknown>;

beforeEach(() => {
changes = {};
});

test('initializes change record if not present', () => {
markSynced(changes, 'a', 'data', Date.now());
expect(changes.a).toBeDefined();
});

test('initializes revision id if not present', () => {
markSynced(changes, 'a', 'data', Date.now());
expect(changes.a.revisionId).toEqual(0);
expect(changes.a.lastSyncedRevisionId).toEqual(0);
});

test('records synced revision id', () => {
changes.a = {revisionId: 123};
markSynced(changes, 'a', 'data', Date.now());
expect(changes.a.revisionId).toEqual(123);
expect(changes.a.lastSyncedRevisionId).toEqual(123);
});

test('records synced data', () => {
markSynced(changes, 'a', 'data', Date.now());
expect(changes.a.lastSyncedData).toEqual('data');
});

test('records synced date', () => {
const at = Date.now();
markSynced(changes, 'a', 'data', at);
expect(changes.a.lastSyncedAt).toEqual(at);
});

test('preserves other properties', () => {
changes.a = {
lastModifiedAt: 10000,
};
markSynced(changes, 'a', 'data', Date.now());
expect(changes.a.lastModifiedAt).toEqual(10000);
});
});

describe('getUnsyncedRecords', () => {
test('returns un-synced records', () => {
const changes = {
a: {revisionId: 1, lastSyncedRevisionId: 0},
b: {revisionId: 1, lastSyncedRevisionId: 1},
};
expect(getUnsyncedRecords(changes)).toEqual({
a: {revisionId: 1, lastSyncedRevisionId: 0},
});
});
});

describe('isUnsynced', () => {
test('returns synced for empty records', () => {
expect(isUnsynced({})).toBeFalsy();
});

test('returns synced for records with matching revision ids', () => {
expect(
isUnsynced({
revisionId: 10,
lastSyncedRevisionId: 10,
}),
).toBeFalsy();
});

test('returns unsynced for records without matching revision ids', () => {
expect(
isUnsynced({
revisionId: 10,
lastSyncedRevisionId: 9,
}),
).toBeTruthy();
});

test('returns unsynced for never-synced records', () => {
expect(
isUnsynced({
revisionId: 10,
}),
).toBeTruthy();
});
});
});
Loading
Loading