From fbdcacd5b73be4950e67b8cafe5a7e5f20bf2295 Mon Sep 17 00:00:00 2001 From: "garo (they/them)" <3411715+shrouxm@users.noreply.github.com> Date: Fri, 13 Oct 2023 16:35:10 -0700 Subject: [PATCH] feat: soil ID slice/service (#105) --- package-lock.json | 4 +- package.json | 2 +- src/account/accountService.ts | 2 +- src/account/accountSlice.ts | 10 +- src/project/projectFragments.ts | 1 + src/project/projectService.ts | 138 +++++++++--------- src/project/projectSlice.ts | 201 ++++++++++++--------------- src/site/siteService.ts | 25 ++-- src/site/siteSlice.ts | 18 +-- src/soilId/soilIdFragments.ts | 110 +++++++++++++++ src/soilId/soilIdService.ts | 238 ++++++++++++++++++++++++++++++++ src/soilId/soilIdSlice.ts | 205 +++++++++++++++++++++++++++ src/store/store.ts | 2 + src/store/utils.ts | 102 ++++---------- src/terrasoApi/utils.ts | 36 ++--- src/utils.ts | 2 + 16 files changed, 781 insertions(+), 315 deletions(-) create mode 100644 src/soilId/soilIdFragments.ts create mode 100644 src/soilId/soilIdService.ts create mode 100644 src/soilId/soilIdSlice.ts create mode 100644 src/utils.ts diff --git a/package-lock.json b/package-lock.json index 984b8abe..4beffe13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "lodash": "^4.17.21", "react": "^18.2.0", "react-redux": "^8.1.2", - "terraso-backend": "github:techmatters/terraso-backend#4929e5f", + "terraso-backend": "github:techmatters/terraso-backend#5476cb6", "uuid": "^9.0.1" }, "devDependencies": { @@ -13101,7 +13101,7 @@ }, "node_modules/terraso-backend": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/techmatters/terraso-backend.git#4929e5f4599cebc2f107873dd0075885c4df5e46" + "resolved": "git+ssh://git@github.com/techmatters/terraso-backend.git#5476cb6b79d3a269a4f6ed5d934c3fc68c555244" }, "node_modules/test-exclude": { "version": "6.0.0", diff --git a/package.json b/package.json index b5f2c840..04724c15 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "lodash": "^4.17.21", "react": "^18.2.0", "react-redux": "^8.1.2", - "terraso-backend": "github:techmatters/terraso-backend#4929e5f", + "terraso-backend": "github:techmatters/terraso-backend#5476cb6", "uuid": "^9.0.1" }, "scripts": { diff --git a/src/account/accountService.ts b/src/account/accountService.ts index cf5d5066..278d3d29 100644 --- a/src/account/accountService.ts +++ b/src/account/accountService.ts @@ -25,7 +25,7 @@ import type { } from 'terraso-client-shared/graphqlSchema/graphql'; import * as terrasoApi from 'terraso-client-shared/terrasoApi/api'; -const parsePreferences = ( +export const parsePreferences = ( user: UserFieldsFragment & UserPreferencesFragment, ): User => ({ ...user, diff --git a/src/account/accountSlice.ts b/src/account/accountSlice.ts index 414ca186..1cc4ea56 100644 --- a/src/account/accountSlice.ts +++ b/src/account/accountSlice.ts @@ -14,7 +14,7 @@ * 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 { createAction, createSlice } from '@reduxjs/toolkit'; +import { createSlice } from '@reduxjs/toolkit'; import _ from 'lodash/fp'; import * as accountService from 'terraso-client-shared/account/accountService'; import { getToken, removeToken } from 'terraso-client-shared/account/auth'; @@ -58,8 +58,6 @@ export type User = { preferences: Record; }; -export const setUsers = createAction>('user/setUsers'); - export const setHasAccessTokenAsync = createAsyncThunk( 'account/setHasAccessTokenAsync', () => getToken(), @@ -112,6 +110,9 @@ export const userSlice = createSlice({ hasToken: action.payload, }), setUsers: (state, { payload: users }) => { + state.users = users; + }, + updateUsers: (state, { payload: users }) => { Object.assign(state.users, users); }, @@ -269,7 +270,8 @@ export const userSlice = createSlice({ }, }); -export const { setUser, setHasToken, addUser } = userSlice.actions; +export const { setUser, setUsers, updateUsers, setHasToken, addUser } = + userSlice.actions; export default userSlice.reducer; diff --git a/src/project/projectFragments.ts b/src/project/projectFragments.ts index 9330bc61..29dcba9c 100644 --- a/src/project/projectFragments.ts +++ b/src/project/projectFragments.ts @@ -21,6 +21,7 @@ export const projectMembershipFields = /* GraphQL */ ` userRole user { ...userFields + ...userPreferences } } `; diff --git a/src/project/projectService.ts b/src/project/projectService.ts index 91a3dd5f..261a209d 100644 --- a/src/project/projectService.ts +++ b/src/project/projectService.ts @@ -15,6 +15,7 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ +import { parsePreferences } from 'terraso-client-shared/account/accountService'; import type { User } from 'terraso-client-shared/account/accountSlice'; import { graphql } from 'terraso-client-shared/graphqlSchema'; import type { @@ -23,83 +24,70 @@ import type { ProjectArchiveMutationInput, ProjectDataFragment, ProjectDeleteMutationInput, + ProjectMembershipFieldsFragment, ProjectUpdateMutationInput, } from 'terraso-client-shared/graphqlSchema/graphql'; -import type { - HydratedProject, - Project, - ProjectMembership, - SerializableSet, -} from 'terraso-client-shared/project/projectSlice'; -import { collapseSiteFields } from 'terraso-client-shared/site/siteService'; -import { Site } from 'terraso-client-shared/site/siteSlice'; +import { collapseSites } from 'terraso-client-shared/site/siteService'; import * as terrasoApi from 'terraso-client-shared/terrasoApi/api'; import { - collapseConnectionEdges, - collapseFields, + collapseEdges, + collapseMaps, + collapseToSet, + Connection, } from 'terraso-client-shared/terrasoApi/utils'; -const collapseProjectFields = collapseFields< - ProjectDataFragment, - HydratedProject ->( - { - dehydrated: inp => { - const siteIds = inp.siteSet.edges - .map(edge => edge.node.id) - .reduce((x, y) => ({ ...x, [y]: true }), {} as SerializableSet); - const memberships = - inp.membershipList.memberships?.edges - .map(edge => edge.node) - .reduce( - (x, { id, user, userRole }) => { - if (user === null || user === undefined) { - return x; - } - return { ...x, [id]: { userId: user.id, userRole, id } }; - }, - {} as Record, - ) || {}; - - const { siteSet: _x, membershipList: _y, updatedAt, ...rest } = inp; - const output: Project = { - ...rest, - updatedAt: new Date(updatedAt).toLocaleString(), - siteIds, - memberships, - }; - return output; +const collapseProjectMembership = ({ + user, + ...fields +}: ProjectMembershipFieldsFragment) => ({ + membership: { userId: user.id, ...fields }, + user: parsePreferences(user), +}); + +const collapseProjectMemberships = ( + connection: Connection, +) => { + const memberships = collapseEdges(connection).map(collapseProjectMembership); + return { + memberships: Object.fromEntries( + memberships.map(({ membership }) => [membership.id, membership]), + ), + users: Object.fromEntries(memberships.map(({ user }) => [user.id, user])), + }; +}; + +export const collapseProject = ({ + membershipList, + siteSet, + ...project +}: ProjectDataFragment) => { + const sites = collapseSites(siteSet); + const { memberships, users } = collapseProjectMemberships( + membershipList.memberships, + ); + return { + project: { + ...project, + sites: collapseToSet(Object.keys(sites)), + memberships, }, - sites: inp => - inp.siteSet.edges - .map(edge => edge.node) - .reduce( - (x, y) => ({ ...x, [y.id]: collapseSiteFields(y) }), - {} as Record, - ), - users: inp => - inp.membershipList.memberships?.edges - .map(({ node: { user } }) => { - if (user === undefined || user === null) { - return undefined; - } - return { - ...user, - preferences: {}, - }; - }) - .reduce( - (x, y) => { - if (y !== undefined) { - return { ...x, [y.id]: y }; - } - return x; - }, - {} as Record, - ) || {}, - }, - true, -); + sites, + users, + }; +}; + +export const collapseProjects = ( + projectConnection: Connection, +) => { + const projects = collapseEdges(projectConnection).map(collapseProject); + return { + projects: Object.fromEntries( + projects.map(({ project }) => [project.id, project]), + ), + sites: collapseMaps(...projects.map(({ sites }) => sites)), + users: collapseMaps(...projects.map(({ users }) => users)), + }; +}; export const fetchProject = (id: string) => { const query = graphql(` @@ -112,7 +100,7 @@ export const fetchProject = (id: string) => { return terrasoApi .requestGraphQL(query, { id }) - .then(resp => collapseProjectFields(resp.project)); + .then(resp => collapseProject(resp.project)); }; export const fetchProjectsForUser = async (_: undefined, user: User | null) => { @@ -134,9 +122,7 @@ export const fetchProjectsForUser = async (_: undefined, user: User | null) => { return terrasoApi .requestGraphQL(query, { id: user.id }) - .then(resp => - collapseConnectionEdges(resp.projects).map(collapseProjectFields), - ); + .then(resp => collapseProjects(resp.projects)); }; export const addProject = (project: ProjectAddMutationInput) => { @@ -153,7 +139,7 @@ export const addProject = (project: ProjectAddMutationInput) => { return terrasoApi .requestGraphQL(query, { input: project }) - .then(resp => collapseProjectFields(resp.addProject.project)); + .then(resp => collapseProject(resp.addProject.project)); }; export const updateProject = (project: ProjectUpdateMutationInput) => { @@ -170,7 +156,7 @@ export const updateProject = (project: ProjectUpdateMutationInput) => { return terrasoApi .requestGraphQL(query, { input: project }) - .then(resp => collapseProjectFields(resp.updateProject.project!)); + .then(resp => collapseProject(resp.updateProject.project!)); }; export const deleteProject = (project: ProjectDeleteMutationInput) => { diff --git a/src/project/projectSlice.ts b/src/project/projectSlice.ts index a3d439f1..ab34a07a 100644 --- a/src/project/projectSlice.ts +++ b/src/project/projectSlice.ts @@ -15,33 +15,24 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ -import { createAction, createSlice } from '@reduxjs/toolkit'; +import { createAction, createSlice, PayloadAction } from '@reduxjs/toolkit'; import { addUser, setUsers, - User, + updateUsers, } from 'terraso-client-shared/account/accountSlice'; import { ProjectAddUserMutationInput, UserRole, } from 'terraso-client-shared/graphqlSchema/graphql'; import * as projectService from 'terraso-client-shared/project/projectService'; -import { setSites, Site } from 'terraso-client-shared/site/siteSlice'; +import { setSites, updateSites } from 'terraso-client-shared/site/siteSlice'; import { createAsyncThunk, - dehydrated, + dispatchByKeys, + SerializableSet, } from 'terraso-client-shared/store/utils'; -const { plural: dehydrateProjects, sing: dehydrateProject } = dehydrated< - Project, - HydratedProject ->({ - users: setUsers, - sites: setSites, -}); - -export type SerializableSet = Record; - export type ProjectMembership = { userId: string; userRole: UserRole; @@ -55,20 +46,10 @@ export type Project = { description: string; updatedAt: string; // this should be Date.toLocaleDateString; redux can't serialize Dates memberships: Record; - siteIds: SerializableSet; + sites: SerializableSet; archived: boolean; }; -export type HydratedProject = { - dehydrated: Project; - users: Record; - sites: Record; -}; - -const initialState = { - projects: {} as Record, -}; - interface MembershipKey { projectId: string; membershipId: string; @@ -79,6 +60,77 @@ interface SiteKey { siteId: string; } +const initialState = { + projects: {} as Record, +}; + +const projectSlice = createSlice({ + name: 'project', + initialState, + reducers: { + setProjects: (state, action: PayloadAction>) => { + state.projects = action.payload; + }, + updateProjects: (state, action: PayloadAction>) => { + Object.assign(state.projects, action.payload); + }, + }, + extraReducers: builder => { + builder.addCase( + addSiteToProject, + (state, { payload: { siteId, projectId } }) => { + state.projects[projectId].sites[siteId] = true; + }, + ); + + builder.addCase( + removeMembershipFromProject, + (state, { payload: { membershipId, projectId } }) => { + delete state.projects[projectId].memberships[membershipId]; + }, + ); + + builder.addCase( + removeSiteFromProject, + (state, { payload: { siteId, projectId } }) => { + delete state.projects[projectId].sites[siteId]; + }, + ); + + builder.addCase(removeSiteFromAllProjects, (state, { payload: siteId }) => { + for (let project of Object.values(state.projects)) { + if (siteId in project.sites) { + delete project.sites[siteId]; + } + } + }); + + builder.addCase(deleteProject.fulfilled, (state, { meta }) => { + delete state.projects[meta.arg.id]; + }); + + builder.addCase( + archiveProject.fulfilled, + (state, { meta, payload: archived }) => { + state.projects[meta.arg.id].archived = archived; + }, + ); + + builder.addCase( + addUserToProject.fulfilled, + (state, { meta, payload: { id: membershipId, userRole, userId } }) => { + state.projects[meta.arg.projectId].memberships[membershipId] = { + id: membershipId, + userRole, + userId, + }; + }, + ); + }, +}); + +export const { setProjects, updateProjects } = projectSlice.actions; + export const removeMembershipFromProject = createAction( 'project/removeMembershipFromProject', ); @@ -99,24 +151,34 @@ export const removeSiteFromAllProjects = createAction( 'project/removeSiteFromAllProjects', ); +const updateDispatchMap = { + project: (project: Project) => updateProjects({ [project.id]: project }), + sites: updateSites, + users: updateUsers, +}; + export const fetchProject = createAsyncThunk( 'project/fetchProject', - dehydrateProject(projectService.fetchProject), + dispatchByKeys(projectService.fetchProject, updateDispatchMap), ); export const fetchProjectsForUser = createAsyncThunk( 'project/fetchProjectsForUser', - dehydrateProjects(projectService.fetchProjectsForUser), + dispatchByKeys(projectService.fetchProjectsForUser, { + projects: setProjects, + sites: setSites, + users: setUsers, + }), ); export const addProject = createAsyncThunk( 'project/addProject', - dehydrateProject(projectService.addProject), + dispatchByKeys(projectService.addProject, updateDispatchMap), ); export const updateProject = createAsyncThunk( 'project/updateProject', - dehydrateProject(projectService.updateProject), + dispatchByKeys(projectService.updateProject, updateDispatchMap), ); export const deleteProject = createAsyncThunk( @@ -147,85 +209,4 @@ export const addUserToProject = createAsyncThunk< }; }); -const projectSlice = createSlice({ - name: 'project', - initialState, - reducers: {}, - extraReducers: builder => { - builder.addCase( - addSiteToProject, - (state, { payload: { siteId, projectId } }) => { - state.projects[projectId].siteIds[siteId] = true; - }, - ); - - builder.addCase( - removeMembershipFromProject, - (state, { payload: { membershipId, projectId } }) => { - delete state.projects[projectId].memberships[membershipId]; - }, - ); - - builder.addCase( - removeSiteFromProject, - (state, { payload: { siteId, projectId } }) => { - delete state.projects[projectId].siteIds[siteId]; - }, - ); - - builder.addCase(removeSiteFromAllProjects, (state, { payload: siteId }) => { - for (let project of Object.values(state.projects)) { - if (siteId in project.siteIds) { - delete project.siteIds[siteId]; - } - } - }); - - // TODO: add case to delete project if not found - builder.addCase(fetchProject.fulfilled, (state, { payload: project }) => { - state.projects[project.id] = project; - }); - - builder.addCase( - fetchProjectsForUser.fulfilled, - (state, { payload: projects }) => { - Object.assign( - state.projects, - Object.fromEntries(projects.map(project => [project.id, project])), - ); - }, - ); - - builder.addCase(addProject.fulfilled, (state, { payload: project }) => { - state.projects[project.id] = project; - }); - - builder.addCase(updateProject.fulfilled, (state, { payload: project }) => { - state.projects[project.id] = project; - }); - - builder.addCase(deleteProject.fulfilled, (state, { meta }) => { - delete state.projects[meta.arg.id]; - }); - - builder.addCase( - archiveProject.fulfilled, - (state, { meta, payload: archived }) => { - state.projects[meta.arg.id].archived = archived; - }, - ); - - builder.addCase( - addUserToProject.fulfilled, - (state, { meta, payload: { id: membershipId, userRole, userId } }) => { - state.projects[meta.arg.projectId].memberships[membershipId] = { - id: membershipId, - userRole, - userId, - }; - }, - ); - }, -}); - export default projectSlice.reducer; diff --git a/src/site/siteService.ts b/src/site/siteService.ts index 723565af..04f67b7c 100644 --- a/src/site/siteService.ts +++ b/src/site/siteService.ts @@ -24,9 +24,12 @@ import type { } from 'terraso-client-shared/graphqlSchema/graphql'; import type { Site } from 'terraso-client-shared/site/siteSlice'; import * as terrasoApi from 'terraso-client-shared/terrasoApi/api'; -import { collapseConnectionEdges } from 'terraso-client-shared/terrasoApi/utils'; +import { + collapseEdges, + Connection, +} from 'terraso-client-shared/terrasoApi/utils'; -export const collapseSiteFields = (site: SiteDataFragment): Site => { +export const collapseSite = (site: SiteDataFragment): Site => { const { project, owner, ...rest } = site; return { ...rest, @@ -34,6 +37,10 @@ export const collapseSiteFields = (site: SiteDataFragment): Site => { ownerId: owner?.id, }; }; +export const collapseSites = (sites: Connection) => + Object.fromEntries( + collapseEdges(sites).map(site => [site.id, collapseSite(site)]), + ); export const fetchSite = (id: string) => { const query = graphql(` @@ -46,7 +53,7 @@ export const fetchSite = (id: string) => { return terrasoApi .requestGraphQL(query, { id }) - .then(resp => collapseSiteFields(resp.site)); + .then(resp => collapseSite(resp.site)); }; export const fetchSitesForProject = (id: string) => { @@ -64,7 +71,7 @@ export const fetchSitesForProject = (id: string) => { return terrasoApi .requestGraphQL(query, { id }) - .then(resp => collapseConnectionEdges(resp.sites).map(collapseSiteFields)); + .then(resp => collapseEdges(resp.sites).map(collapseSite)); }; export const fetchSitesForUser = async (_: undefined, user: User | null) => { @@ -94,9 +101,9 @@ export const fetchSitesForUser = async (_: undefined, user: User | null) => { return terrasoApi .requestGraphQL(query, { id: user.id }) .then(resp => - collapseConnectionEdges(resp.userSites) - .concat(collapseConnectionEdges(resp.projectSites)) - .map(collapseSiteFields), + collapseEdges(resp.userSites) + .concat(collapseEdges(resp.projectSites)) + .map(collapseSite), ); }; @@ -114,7 +121,7 @@ export const addSite = (site: SiteAddMutationInput) => { return terrasoApi .requestGraphQL(query, { input: site }) - .then(resp => collapseSiteFields(resp.addSite.site)); + .then(resp => collapseSite(resp.addSite.site)); }; export const updateSite = (site: SiteUpdateMutationInput) => { @@ -131,7 +138,7 @@ export const updateSite = (site: SiteUpdateMutationInput) => { return terrasoApi .requestGraphQL(query, { input: site }) - .then(resp => collapseSiteFields(resp.updateSite.site!)); + .then(resp => collapseSite(resp.updateSite.site!)); }; export const deleteSite = (site: Site) => { diff --git a/src/site/siteSlice.ts b/src/site/siteSlice.ts index ccae3920..eb186cd1 100644 --- a/src/site/siteSlice.ts +++ b/src/site/siteSlice.ts @@ -15,7 +15,7 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ -import { createAction, createSlice } from '@reduxjs/toolkit'; +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { SiteAddMutationInput } from 'terraso-client-shared/graphqlSchema/graphql'; import { addSiteToProject, @@ -42,8 +42,6 @@ const initialState = { sites: {} as Record, }; -export const setSites = createAction>('site/setSites'); - export const fetchSite = createAsyncThunk( 'site/fetchSite', siteService.fetchSite, @@ -87,12 +85,15 @@ export const deleteSite = createAsyncThunk( const siteSlice = createSlice({ name: 'site', initialState, - reducers: {}, + reducers: { + setSites: (state, { payload }: PayloadAction>) => { + state.sites = payload; + }, + updateSites: (state, { payload }: PayloadAction>) => { + Object.assign(state.sites, payload.sites); + }, + }, extraReducers: builder => { - builder.addCase(setSites, (state, { payload: sites }) => { - Object.assign(state.sites, sites); - }); - // TODO: add case to delete site if not found builder.addCase(fetchSite.fulfilled, (state, { payload: site }) => { state.sites[site.id] = site; @@ -135,4 +136,5 @@ const siteSlice = createSlice({ }, }); +export const { setSites, updateSites } = siteSlice.actions; export default siteSlice.reducer; diff --git a/src/soilId/soilIdFragments.ts b/src/soilId/soilIdFragments.ts new file mode 100644 index 00000000..f68733e3 --- /dev/null +++ b/src/soilId/soilIdFragments.ts @@ -0,0 +1,110 @@ +/* + * Copyright © 2023 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/. + */ + +export const projectSoilSettings = /* GraphQL */ ` + fragment projectSoilSettings on ProjectSoilSettingsNode { + depthIntervals { + depthInterval { + start + end + } + label + } + measurementUnits + depthIntervalPreset + soilPitRequired + slopeRequired + soilTextureRequired + soilColorRequired + verticalCrackingRequired + carbonatesRequired + phRequired + soilOrganicCarbonMatterRequired + electricalConductivityRequired + sodiumAdsorptionRatioRequired + soilStructureRequired + landUseLandCoverRequired + soilLimitationsRequired + photosRequired + notesRequired + } +`; + +export const soilData = /* GraphQL */ ` + fragment soilData on SoilDataNode { + downSlope + crossSlope + bedrock + slopeLandscapePosition + slopeAspect + slopeSteepnessSelect + slopeSteepnessPercent + slopeSteepnessDegree + depthIntervals { + ...soilDataDepthInterval + } + depthDependentData { + ...depthDependentSoilData + } + } +`; + +export const soilDataDepthInterval = /* GraphQL */ ` + fragment soilDataDepthInterval on SoilDataDepthIntervalNode { + label + depthInterval { + start + end + } + soilTextureEnabled + soilColorEnabled + carbonatesEnabled + phEnabled + soilOrganicCarbonMatterEnabled + electricalConductivityEnabled + sodiumAdsorptionRatioEnabled + soilStructureEnabled + } +`; + +export const depthDependentSoilData = /* GraphQL */ ` + fragment depthDependentSoilData on DepthDependentSoilDataNode { + depthInterval { + start + end + } + texture + rockFragmentVolume + colorHueSubstep + colorHue + colorValue + colorChroma + conductivity + conductivityTest + conductivityUnit + structure + ph + phTestingSolution + phTestingMethod + soilOrganicCarbon + soilOrganicMatter + soilOrganicCarbonTesting + soilOrganicMatterTesting + sodiumAbsorptionRatio + carbonates + } +`; diff --git a/src/soilId/soilIdService.ts b/src/soilId/soilIdService.ts new file mode 100644 index 00000000..157d8f16 --- /dev/null +++ b/src/soilId/soilIdService.ts @@ -0,0 +1,238 @@ +/* + * Copyright © 2023 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 { graphql } from 'terraso-client-shared/graphqlSchema'; +import type { + DepthDependentSoilDataUpdateMutationInput, + ProjectSoilSettingsDeleteDepthIntervalMutationInput, + ProjectSoilSettingsUpdateDepthIntervalMutationInput, + ProjectSoilSettingsUpdateMutationInput, + SoilDataDeleteDepthIntervalMutationInput, + SoilDataUpdateDepthIntervalMutationInput, + SoilDataUpdateMutationInput, +} from 'terraso-client-shared/graphqlSchema/graphql'; +import { collapseProjects } from 'terraso-client-shared/project/projectService'; +import { collapseSites } from 'terraso-client-shared/site/siteService'; +import * as terrasoApi from 'terraso-client-shared/terrasoApi/api'; +import { + collapseEdges, + collapseMaps, +} from 'terraso-client-shared/terrasoApi/utils'; + +export const fetchSoilDataForUser = async (userId: string) => { + const query = graphql(` + query userSoilData($id: ID!) { + userSites: sites(owner: $id) { + edges { + node { + ...siteData + soilData { + ...soilData + } + } + } + } + projects: projects(member: $id) { + edges { + node { + ...projectData + siteSet { + edges { + node { + soilData { + ...soilData + } + } + } + } + soilSettings { + ...projectSoilSettings + } + } + } + } + } + `); + + const { userSites, projects: allProjects } = await terrasoApi.requestGraphQL( + query, + { id: userId }, + ); + + const { + projects, + sites: projectSites, + users, + } = collapseProjects(allProjects); + const allSites = collapseEdges(userSites).concat( + collapseEdges(allProjects).flatMap(({ siteSet }) => collapseEdges(siteSet)), + ); + + return { + projects, + users, + projectSoilSettings: Object.fromEntries( + collapseEdges(allProjects).map(({ soilSettings, id }) => [ + id, + soilSettings, + ]), + ), + sites: collapseMaps(collapseSites(userSites), projectSites), + soilData: Object.fromEntries( + allSites.map(({ soilData, id }) => [id, soilData]), + ), + }; +}; + +export const updateSoilData = async (soilData: SoilDataUpdateMutationInput) => { + const query = graphql(` + mutation updateSoilData($input: SoilDataUpdateMutationInput!) { + updateSoilData(input: $input) { + soilData { + ...soilData + } + errors + } + } + `); + + const resp = await terrasoApi.requestGraphQL(query, { input: soilData }); + return resp.updateSoilData.soilData!; +}; + +export const updateDepthDependentSoilData = async ( + depthDependentData: DepthDependentSoilDataUpdateMutationInput, +) => { + const query = graphql(` + mutation updateDepthDependentSoilData( + $input: DepthDependentSoilDataUpdateMutationInput! + ) { + updateDepthDependentSoilData(input: $input) { + soilData { + ...soilData + } + errors + } + } + `); + + const resp = await terrasoApi.requestGraphQL(query, { + input: depthDependentData, + }); + + return resp.updateDepthDependentSoilData.soilData!; +}; + +export const updateSoilDataDepthInterval = async ( + soilData: SoilDataUpdateDepthIntervalMutationInput, +) => { + const query = graphql(` + mutation updateSoilDataDepthInterval( + $input: SoilDataUpdateDepthIntervalMutationInput! + ) { + updateSoilDataDepthInterval(input: $input) { + soilData { + ...soilData + } + errors + } + } + `); + + const resp = await terrasoApi.requestGraphQL(query, { input: soilData }); + return resp.updateSoilDataDepthInterval.soilData!; +}; + +export const deleteSoilDataDepthInterval = async ( + soilData: SoilDataDeleteDepthIntervalMutationInput, +) => { + const query = graphql(` + mutation deleteSoilDataDepthInterval( + $input: SoilDataDeleteDepthIntervalMutationInput! + ) { + deleteSoilDataDepthInterval(input: $input) { + soilData { + ...soilData + } + errors + } + } + `); + + const resp = await terrasoApi.requestGraphQL(query, { input: soilData }); + return resp.deleteSoilDataDepthInterval.soilData!; +}; + +export const updateProjectSoilSettings = async ( + soilSettings: ProjectSoilSettingsUpdateMutationInput, +) => { + const query = graphql(` + mutation updateProjectSoilSettings( + $input: ProjectSoilSettingsUpdateMutationInput! + ) { + updateProjectSoilSettings(input: $input) { + projectSoilSettings { + ...projectSoilSettings + } + errors + } + } + `); + + const resp = await terrasoApi.requestGraphQL(query, { input: soilSettings }); + return resp.updateProjectSoilSettings.projectSoilSettings!; +}; + +export const updateProjectDepthInterval = async ( + depthInterval: ProjectSoilSettingsUpdateDepthIntervalMutationInput, +) => { + const query = graphql(` + mutation updateProjectSoilSettingsDepthInterval( + $input: ProjectSoilSettingsUpdateDepthIntervalMutationInput! + ) { + updateProjectSoilSettingsDepthInterval(input: $input) { + projectSoilSettings { + ...projectSoilSettings + } + errors + } + } + `); + + const resp = await terrasoApi.requestGraphQL(query, { input: depthInterval }); + return resp.updateProjectSoilSettingsDepthInterval.projectSoilSettings!; +}; + +export const deleteProjectDepthInterval = async ( + depthInterval: ProjectSoilSettingsDeleteDepthIntervalMutationInput, +) => { + const query = graphql(` + mutation deleteProjectSoilSettingsDepthInterval( + $input: ProjectSoilSettingsDeleteDepthIntervalMutationInput! + ) { + deleteProjectSoilSettingsDepthInterval(input: $input) { + projectSoilSettings { + ...projectSoilSettings + } + errors + } + } + `); + + const resp = await terrasoApi.requestGraphQL(query, { input: depthInterval }); + return resp.deleteProjectSoilSettingsDepthInterval.projectSoilSettings!; +}; diff --git a/src/soilId/soilIdSlice.ts b/src/soilId/soilIdSlice.ts new file mode 100644 index 00000000..58ddc3f8 --- /dev/null +++ b/src/soilId/soilIdSlice.ts @@ -0,0 +1,205 @@ +/* + * Copyright © 2023 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 { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { setUsers } from 'terraso-client-shared/account/accountSlice'; +import type { + DepthDependentSoilDataNode, + DepthInterval, + ProjectDepthIntervalNode, + ProjectSoilSettingsNode, + SoilDataDepthIntervalNode, + SoilDataNode, +} from 'terraso-client-shared/graphqlSchema/graphql'; +import { setProjects } from 'terraso-client-shared/project/projectSlice'; +import { setSites } from 'terraso-client-shared/site/siteSlice'; +import * as soilIdService from 'terraso-client-shared/soilId/soilIdService'; +import { + createAsyncThunk, + dispatchByKeys, +} from 'terraso-client-shared/store/utils'; + +export const soilPitMethods = [ + 'soilTexture', + 'soilColor', + 'carbonates', + 'ph', + 'soilOrganicCarbonMatter', + 'electricalConductivity', + 'sodiumAdsorptionRatio', + 'soilStructure', +] as const; +export const collectionMethods = [ + 'slope', + 'verticalCracking', + ...soilPitMethods, + 'landUseLandCover', + 'soilLimitations', + 'photos', + 'notes', +] as const; + +export type SoilPitMethod = (typeof soilPitMethods)[number]; +export type CollectionMethod = (typeof collectionMethods)[number]; + +export const methodEnabled = ( + method: T, +): `${T}Enabled` => `${method}Enabled`; + +export const methodRequired = ( + method: T, +): `${T}Required` => `${method}Required`; + +export { DepthInterval }; +export type LabelledDepthInterval = { + label: string; + depthInterval: DepthInterval; +}; +export type SoilDataDepthInterval = Omit; +export type DepthDependentSoilData = Omit; +export type SoilData = Omit< + SoilDataNode, + 'site' | 'depthIntervals' | 'depthDependentData' +> & { + depthIntervals: SoilDataDepthInterval[]; + depthDependentData: DepthDependentSoilData[]; +}; +export type ProjectDepthInterval = Omit; +export type ProjectSoilSettings = Omit< + ProjectSoilSettingsNode, + 'project' | 'depthIntervals' +> & { + depthIntervals: ProjectDepthInterval[]; +}; + +const initialState = { + soilData: {} as Record, + projectSettings: {} as Record, +}; + +export const sameDepth = + (a: { depthInterval: DepthInterval }) => + (b: { depthInterval: DepthInterval }) => + a.depthInterval.start === b.depthInterval.start && + a.depthInterval.end === b.depthInterval.end; + +const soilIdSlice = createSlice({ + name: 'soilId', + initialState, + reducers: { + setSoilData: (state, action: PayloadAction>) => { + state.soilData = action.payload; + }, + updateSoilData: ( + state, + action: PayloadAction>, + ) => { + Object.assign(state.soilData, action.payload); + }, + setProjectSettings: ( + state, + action: PayloadAction>, + ) => { + state.projectSettings = action.payload; + }, + updateProjectSettings: ( + state, + action: PayloadAction>, + ) => { + Object.assign(state.projectSettings, action.payload); + }, + }, + extraReducers: builder => { + builder.addCase(updateSoilData.fulfilled, (state, action) => { + state.soilData[action.meta.arg.siteId] = action.payload; + }); + + builder.addCase(updateDepthDependentSoilData.fulfilled, (state, action) => { + state.soilData[action.meta.arg.siteId] = action.payload; + }); + + builder.addCase(updateSoilDataDepthInterval.fulfilled, (state, action) => { + state.soilData[action.meta.arg.siteId] = action.payload; + }); + + builder.addCase(deleteSoilDataDepthInterval.fulfilled, (state, action) => { + state.soilData[action.meta.arg.siteId] = action.payload; + }); + + builder.addCase(updateProjectSoilSettings.fulfilled, (state, action) => { + state.projectSettings[action.meta.arg.projectId] = action.payload; + }); + + builder.addCase(updateProjectDepthInterval.fulfilled, (state, action) => { + state.projectSettings[action.meta.arg.projectId] = action.payload; + }); + + builder.addCase(deleteProjectDepthInterval.fulfilled, (state, action) => { + state.projectSettings[action.meta.arg.projectId] = action.payload; + }); + }, +}); + +export const { setProjectSettings, setSoilData } = soilIdSlice.actions; + +export const fetchSoilDataForUser = createAsyncThunk( + 'soilId/fetchSoilDataForUser', + dispatchByKeys(soilIdService.fetchSoilDataForUser, { + projects: setProjects, + sites: setSites, + projectSoilSettings: setProjectSettings, + soilData: setSoilData, + users: setUsers, + }), +); + +export const updateSoilData = createAsyncThunk( + 'soilId/updateSoilData', + soilIdService.updateSoilData, +); + +export const updateDepthDependentSoilData = createAsyncThunk( + 'soilId/updateDepthDependentSoilData', + soilIdService.updateDepthDependentSoilData, +); + +export const updateSoilDataDepthInterval = createAsyncThunk( + 'soilId/updateSoilDataDepthInterval', + soilIdService.updateSoilDataDepthInterval, +); + +export const deleteSoilDataDepthInterval = createAsyncThunk( + 'soilId/deleteSoilDataDepthInterval', + soilIdService.deleteSoilDataDepthInterval, +); + +export const updateProjectSoilSettings = createAsyncThunk( + 'soilId/updateProjectSoilSettings', + soilIdService.updateProjectSoilSettings, +); + +export const updateProjectDepthInterval = createAsyncThunk( + 'soilId/updateProjectDepthInterval', + soilIdService.updateProjectDepthInterval, +); + +export const deleteProjectDepthInterval = createAsyncThunk( + 'soilId/deleteProjectDepthInterval', + soilIdService.deleteProjectDepthInterval, +); + +export default soilIdSlice.reducer; diff --git a/src/store/store.ts b/src/store/store.ts index 47bd76bf..34e72b06 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -29,6 +29,7 @@ import membershipsReducer from 'terraso-client-shared/memberships/membershipsSli import notificationsReducer from 'terraso-client-shared/notifications/notificationsSlice'; import projectReducer from 'terraso-client-shared/project/projectSlice'; import siteReducer from 'terraso-client-shared/site/siteSlice'; +import soilIdReducer from 'terraso-client-shared/soilId/soilIdSlice'; const handleAbortMiddleware: Middleware = () => next => action => { if (_.getOr(false, 'meta.aborted', action)) { @@ -47,6 +48,7 @@ const sharedReducers = { notifications: notificationsReducer, site: siteReducer, project: projectReducer, + soilId: soilIdReducer, }; // Using some advanced TypeScript features here: since we have diff --git a/src/store/utils.ts b/src/store/utils.ts index d98a67b3..cc6304d5 100644 --- a/src/store/utils.ts +++ b/src/store/utils.ts @@ -36,6 +36,8 @@ import type { SharedState, } from 'terraso-client-shared/store/store'; +export type SerializableSet = Record; + const executeAuthRequest = ( dispatch: SharedDispatch, action: () => Promise, @@ -89,13 +91,15 @@ export type ThunkAPI = BaseThunkAPI< RejectPayload >; +type CreateAsyncThunkParams = ( + arg: ThunkArg, + currentUser: User | null, + thunkAPI: ThunkAPI, +) => Returned | Promise; + export const createAsyncThunk = ( typePrefix: string, - action: ( - arg: ThunkArg, - currentUser: User | null, - thunkAPI: ThunkAPI, - ) => Returned | Promise, + action: CreateAsyncThunkParams, onSuccessMessage: | ((result: Returned, input: ThunkArg) => Message) | null = null, @@ -166,12 +170,6 @@ export const useFetchData = ( }, [dispatch, dataFetchCallback]); }; -export type Thunker = Parameters>[1]; - -interface Dehydratable { - dehydrated: T; -} - type DispatchMap = { [Property in keyof Result]: ( arg: Result[Property], @@ -180,70 +178,20 @@ type DispatchMap = { | ThunkAction; }; -type ThunkFunct> = { - sing: (fetcher: Thunker) => Thunker; - plural: (fetcher: Thunker) => Thunker; -}; - -export function dehydrated< - Dehydrated, - Hydrated extends Dehydratable, ->( - dispatchMap: DispatchMap>, -): ThunkFunct { - const sing = (fetcher: Thunker): Thunker => { - return async (arg, currentUser, thunkAPI) => { - const result = await fetcher(arg, currentUser, thunkAPI); - let { dehydrated: _, ...toDispatch } = { - ...result, - }; - dispatchAll(toDispatch, dispatchMap, thunkAPI.dispatch); - return result.dehydrated; - }; - }; - const plural = ( - fetcher: Thunker, - ): Thunker => { - return async (arg, currentUser, thunkAPI) => { - const result = await fetcher(arg, currentUser, thunkAPI); - if (result.length === 0) { - return []; - } - - let toDispatch: any = {}; - for (let key of Object.keys(result[0]) as (keyof Hydrated)[]) { - let res = result[0]; - toDispatch[key] = { ...res[key] }; - } - delete toDispatch['dehydrated']; - let returnItems = [result[0].dehydrated]; - for (let res of result.slice(1)) { - for (let key of Object.keys(toDispatch)) { - Object.assign(toDispatch[key], { - ...res[key as keyof typeof res], - }); - } - returnItems.push(res.dehydrated); - } - - dispatchAll(toDispatch, dispatchMap, thunkAPI.dispatch); - return returnItems; - }; - }; - return { sing, plural }; -} - -function dispatchAll( - toDispatch: T, +export const dispatchByKeys = ( + fetcher: CreateAsyncThunkParams, dispatchMap: DispatchMap, - dispatch: ThunkAPI['dispatch'], -) { - let dispatches = []; - for (let key in toDispatch) { - let f = dispatchMap[key]; - let args = toDispatch[key]; - dispatches.push(dispatch(f(args))); - } - // in case we ever wanted to await this - return Promise.all(dispatches); -} +): CreateAsyncThunkParams => { + return async (arg, currentUser, thunkAPI) => { + return Object.fromEntries( + await Promise.all( + Object.entries(await fetcher(arg, currentUser, thunkAPI)).map( + async ([key, result]) => { + await thunkAPI.dispatch(dispatchMap[key as keyof T](result)); + return [key, result]; + }, + ), + ), + ); + }; +}; diff --git a/src/terrasoApi/utils.ts b/src/terrasoApi/utils.ts index c63822f8..25ef5b0a 100644 --- a/src/terrasoApi/utils.ts +++ b/src/terrasoApi/utils.ts @@ -15,35 +15,17 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ -export const collapseConnectionEdges = (connection: { +export type Connection = { edges: { node: T }[]; -}): T[] => { +}; + +export const collapseEdges = (connection: Connection): T[] => { return connection.edges.map(({ node }) => node); }; -type MandatoryFields = Partial & // fields in Input, but not Output: optional - Omit; // fields in Output, but not Input: mandatory +export const collapseToSet = (values: string[]) => + Object.fromEntries(values.map(value => [value, true])); -export function collapseFields( - changes: { - [Property in keyof MandatoryFields]: ( - inp: Input, - ) => Output[Property]; - }, - clean: boolean = false, -): (input: Input) => Output { - return (input: Input) => { - const update = (Object.keys(changes) as (keyof typeof changes)[]).reduce( - (output, field) => { - let result = changes[field](input); - output[field as keyof Output] = result; - return output; - }, - {} as Output, - ); - if (clean) { - return update; - } - return { ...input, ...update }; - }; -} +export const collapseMaps = ( + ...maps: Record[] +): Record => Object.assign({}, ...maps); diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 00000000..0de5ac9d --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,2 @@ +export const fromEntries = (entries: [K, V][]) => + Object.fromEntries(entries) as Record;