From 21c7d1de7543b0b9e82c7a65dc5aa7428e0dde22 Mon Sep 17 00:00:00 2001 From: Nishit Suwal <81785002+NSUWAL123@users.noreply.github.com> Date: Fri, 28 Feb 2025 04:18:45 +0545 Subject: [PATCH] feat(frontend): create new feature entities via API prior to ODK collect usage (#2156) * feat(projectDetailsV2): layer add to display new geometries * fix(collect): pass newly created entityId to intent URL instead of passing whole geom * feat(entities): create entity & geometry record function * feat(+page): update newFeature creation workflow passing entity uuid to intent URL * fix(+page): pass entity id as property of new geom record * fix(ITask): update TaskFeatureSelectionProperties type * feat(projectDetailsV2): style new geom feature acc to entity status * refactor(featureSelectionPopup): display feature popup for new entity as well * fix(main): show new entity on map * fix(entities): use entity id instead of osm id to make entity actions available for new features as well * refactor(reviewStateModal): remove featureId from SetUpdateReviewStatusModal action dispatch * fix(+page): after odk redirection close confirmation popup * fix(dialog-entities-actions): update condition --- .../FeatureSelectionPopup.tsx | 50 +-- .../ProjectSubmissions/SubmissionsTable.tsx | 1 - .../UpdateReviewStatusModal.tsx | 6 +- src/frontend/src/store/types/ISubmissions.ts | 1 - src/frontend/src/store/types/ITask.ts | 11 +- src/frontend/src/views/ProjectDetailsV2.tsx | 27 ++ src/frontend/src/views/SubmissionDetails.tsx | 1 - .../components/dialog-entities-actions.svelte | 6 +- src/mapper/src/lib/components/map/main.svelte | 295 ++++++++++++------ src/mapper/src/lib/odk/collect.ts | 12 +- .../src/routes/[projectId]/+page.svelte | 30 +- src/mapper/src/store/entities.svelte.ts | 59 +++- 12 files changed, 359 insertions(+), 140 deletions(-) diff --git a/src/frontend/src/components/ProjectDetailsV2/FeatureSelectionPopup.tsx b/src/frontend/src/components/ProjectDetailsV2/FeatureSelectionPopup.tsx index c5fd496c45..72e89eab55 100644 --- a/src/frontend/src/components/ProjectDetailsV2/FeatureSelectionPopup.tsx +++ b/src/frontend/src/components/ProjectDetailsV2/FeatureSelectionPopup.tsx @@ -20,7 +20,9 @@ const FeatureSelectionPopup = ({ featureProperties, taskId }: FeatureSelectionPo const taskModalStatus = useAppSelector((state) => state.project.taskModalStatus); const entityOsmMap = useAppSelector((state) => state.project.entityOsmMap); const projectId = params.id || ''; - const entity = entityOsmMap.find((x) => x.osm_id === featureProperties?.osm_id); + const entity = entityOsmMap.find( + (x) => x.osm_id === featureProperties?.osm_id || x.id === featureProperties?.entity_id, + ); const submissionIds = entity?.submission_ids ? entity?.submission_ids?.split(',') : []; return ( @@ -37,7 +39,11 @@ const FeatureSelectionPopup = ({ featureProperties, taskId }: FeatureSelectionPo } fmtm-rounded-t-2xl md:fmtm-rounded-tr-none md:fmtm-rounded-l-2xl`} >
-

Feature: {featureProperties?.osm_id}

+

+ {featureProperties?.osm_id + ? `Feature: ${featureProperties.osm_id}` + : `Entity: ${featureProperties?.entity_id}`} +

-
-
-

- Tags: - {featureProperties?.tags} -

-

- Timestamp: - {featureProperties?.timestamp} -

-

- Changeset: - {featureProperties?.changeset} -

-

- Version: - {featureProperties?.version} -

+ {featureProperties?.changeset && ( +
+
+

+ Tags: + {featureProperties?.tags} +

+

+ Timestamp: + {featureProperties?.timestamp} +

+

+ Changeset: + {featureProperties?.changeset} +

+

+ Version: + {featureProperties?.version} +

+
-
+ )} {(!submissionIds || submissionIds?.length !== 0) && entity && entity_state[entity.status] !== 'VALIDATED' && (
{submissionIds?.length > 1 ? ( diff --git a/src/frontend/src/components/ProjectSubmissions/SubmissionsTable.tsx b/src/frontend/src/components/ProjectSubmissions/SubmissionsTable.tsx index 4cace0bfae..39ef05eda4 100644 --- a/src/frontend/src/components/ProjectSubmissions/SubmissionsTable.tsx +++ b/src/frontend/src/components/ProjectSubmissions/SubmissionsTable.tsx @@ -482,7 +482,6 @@ const SubmissionsTable = ({ toggleView }) => { label: row?.meta?.entity?.label, feature: convertCoordinateStringToFeature('xlocation', row?.xlocation), taskUid: taskUid?.toString() || null, - featureId: row?.xid, }), ); }} diff --git a/src/frontend/src/components/ProjectSubmissions/UpdateReviewStatusModal.tsx b/src/frontend/src/components/ProjectSubmissions/UpdateReviewStatusModal.tsx index 477e2e7117..ce67572bc2 100644 --- a/src/frontend/src/components/ProjectSubmissions/UpdateReviewStatusModal.tsx +++ b/src/frontend/src/components/ProjectSubmissions/UpdateReviewStatusModal.tsx @@ -23,7 +23,6 @@ const initialReviewState = { entity_id: null, label: null, feature: null, - featureId: null, }; // Note these id values must be camelCase to match what ODK Central requires @@ -65,8 +64,7 @@ const UpdateReviewStatusModal = () => { !updateReviewStatusModal.projectId || !updateReviewStatusModal.taskId || !updateReviewStatusModal.entity_id || - !updateReviewStatusModal.taskUid || - !updateReviewStatusModal.featureId + !updateReviewStatusModal.taskUid ) { return; } @@ -132,7 +130,7 @@ const UpdateReviewStatusModal = () => { `${VITE_API_URL}/tasks/${updateReviewStatusModal?.taskUid}/event?project_id=${updateReviewStatusModal?.projectId}`, { task_id: +updateReviewStatusModal?.taskUid, - comment: `#submissionId:${updateReviewStatusModal?.instanceId} #featureId:${updateReviewStatusModal?.featureId} ${noteComments}`, + comment: `#submissionId:${updateReviewStatusModal?.instanceId} #featureId:${updateReviewStatusModal?.entity_id} ${noteComments}`, event: task_event.COMMENT, }, ), diff --git a/src/frontend/src/store/types/ISubmissions.ts b/src/frontend/src/store/types/ISubmissions.ts index deeb7e4de7..3ac6769503 100644 --- a/src/frontend/src/store/types/ISubmissions.ts +++ b/src/frontend/src/store/types/ISubmissions.ts @@ -32,7 +32,6 @@ type updateReviewStatusModal = { entity_id: string | null; label: string | null; feature: featureType | null; - featureId: string | null; }; export type filterType = { diff --git a/src/frontend/src/store/types/ITask.ts b/src/frontend/src/store/types/ITask.ts index e04bb703c4..6b35ae0fea 100644 --- a/src/frontend/src/store/types/ITask.ts +++ b/src/frontend/src/store/types/ITask.ts @@ -18,9 +18,10 @@ type downloadSubmissionLoadingTypes = { }; export type TaskFeatureSelectionProperties = { - osm_id: number; - tags: string; - timestamp: string; - version: number; - changeset: number; + osm_id?: number; + tags?: string; + timestamp?: string; + version?: number; + changeset?: number; + entity_id?: string; }; diff --git a/src/frontend/src/views/ProjectDetailsV2.tsx b/src/frontend/src/views/ProjectDetailsV2.tsx index 08bf171630..5206001e82 100644 --- a/src/frontend/src/views/ProjectDetailsV2.tsx +++ b/src/frontend/src/views/ProjectDetailsV2.tsx @@ -70,6 +70,7 @@ const ProjectDetailsV2 = () => { const entityOsmMap = useAppSelector((state) => state?.project?.entityOsmMap); const entityOsmMapLoading = useAppSelector((state) => state?.project?.entityOsmMapLoading); const badGeomFeatureCollection = useAppSelector((state) => state?.project?.badGeomFeatureCollection); + const newGeomFeatureCollection = useAppSelector((state) => state?.project?.newGeomFeatureCollection); const getGeomLogLoading = useAppSelector((state) => state?.project?.getGeomLogLoading); const syncTaskStateLoading = useAppSelector((state) => state?.project?.syncTaskStateLoading); @@ -479,6 +480,32 @@ const ProjectDetailsV2 = () => { zIndex={5} style="" /> + {/* New geometry layer */} + {/* TODO: + 1. style add to layer, + 2. on feature click show validate button with feature info's + */} + { + const geomType = feature.getGeometry().getType(); + const entity = entityOsmMap?.find( + (entity) => entity?.id === feature?.getProperties()?.entity_id, + ) as EntityOsmMap; + const status = entity_state[entity?.status]; + return getFeatureStatusStyle(geomType, mapTheme, status); + }} + /> {dataExtractUrl && isValidUrl(dataExtractUrl) && dataExtractExtent && selectedTask && ( { entity_id: restSubmissionDetails?.feature, label: restSubmissionDetails?.meta?.entity?.label, feature: convertCoordinateStringToFeature('xlocation', restSubmissionDetails?.xlocation), - featureId: restSubmissionDetails?.xid, }), ); }} diff --git a/src/mapper/src/lib/components/dialog-entities-actions.svelte b/src/mapper/src/lib/components/dialog-entities-actions.svelte index a2ea72abf3..bd0a74d964 100644 --- a/src/mapper/src/lib/components/dialog-entities-actions.svelte +++ b/src/mapper/src/lib/components/dialog-entities-actions.svelte @@ -41,9 +41,9 @@ const alertStore = getAlertStore(); const taskStore = getTaskStore(); - const selectedEntityOsmId = $derived(entitiesStore.selectedEntity); + const selectedEntityId = $derived(entitiesStore.selectedEntity); const selectedEntity = $derived( - entitiesStore.entitiesStatusList?.find((entity) => entity.osmid === selectedEntityOsmId), + entitiesStore.entitiesStatusList?.find((entity) => entity.entity_id === selectedEntityId), ); const selectedEntityCoordinate = $derived(entitiesStore.selectedEntityCoordinate); const entityToNavigate = $derived(entitiesStore.entityToNavigate); @@ -228,7 +228,7 @@ {#if selectedEntity?.status !== 'SURVEY_SUBMITTED' && selectedEntity?.status !== 'VALIDATED'}
0) { const entityCentroid = centroid(clickedEntityFeature[0].geometry); - const clickedEntityId = clickedEntityFeature[0]?.properties?.osm_id; + const clickedEntityId = clickedEntityFeature[0]?.properties?.entity_id; + entitiesStore.setSelectedEntity(clickedEntityId); + entitiesStore.setSelectedEntityCoordinate({ + entityId: clickedEntityId, + coordinate: entityCentroid?.geometry?.coordinates, + }); + } else if (clickedNewEntityFeature && clickedNewEntityFeature?.length > 0) { + const entityCentroid = centroid(clickedNewEntityFeature[0].geometry); + const clickedEntityId = clickedNewEntityFeature[0]?.properties?.entity_id; entitiesStore.setSelectedEntity(clickedEntityId); entitiesStore.setSelectedEntityCoordinate({ entityId: clickedEntityId, @@ -200,7 +215,10 @@ } } - if (clickedEntityFeature && clickedEntityFeature?.length > 0) { + if ( + (clickedEntityFeature && clickedEntityFeature?.length > 0) || + (clickedNewEntityFeature && clickedNewEntityFeature?.length > 0) + ) { toggleActionModal('entity-modal'); } else if (clickedTaskFeature && clickedTaskFeature?.length > 0) { toggleActionModal('task-modal'); @@ -281,21 +299,42 @@ } }); - function addStatusToGeojsonProperty(geojsonData: FeatureCollection): FeatureCollection { - return { - ...geojsonData, - features: geojsonData.features.map((feature) => { - const entity = entitiesStore.entitiesStatusList.find((entity) => entity.osmid === feature?.properties?.osm_id); - return { - ...feature, - properties: { - ...feature.properties, - status: entity?.status, - entity_id: entity?.entity_id, - }, - }; - }), - }; + function addStatusToGeojsonProperty(geojsonData: FeatureCollection, entityType: '' | 'new'): FeatureCollection { + if (entityType === 'new') { + return { + ...geojsonData, + features: geojsonData.features.map((feature) => { + const entity = entitiesStore.entitiesStatusList.find( + (entity) => entity.entity_id === feature?.properties?.entity_id, + ); + return { + ...feature, + properties: { + ...feature.properties, + status: entity?.status, + entity_id: entity?.entity_id, + }, + }; + }), + }; + } else { + return { + ...geojsonData, + features: geojsonData.features.map((feature) => { + const entity = entitiesStore.entitiesStatusList.find( + (entity) => entity.osmid === feature?.properties?.osm_id, + ); + return { + ...feature, + properties: { + ...feature.properties, + status: entity?.status, + entity_id: entity?.entity_id, + }, + }; + }), + }; + } } function zoomToProject() { @@ -495,81 +534,81 @@ extent={taskStore.selectedTaskGeom} extractGeomCols={true} promoteId="id" - processGeojson={(geojsonData) => addStatusToGeojsonProperty(geojsonData)} + processGeojson={(geojsonData) => addStatusToGeojsonProperty(geojsonData, '')} geojsonUpdateDependency={entitiesStore.entitiesStatusList} > - {#if primaryGeomType === MapGeomTypes.POLYGON} - - + {#if primaryGeomType === MapGeomTypes.POLYGON} + + {:else if primaryGeomType === MapGeomTypes.POINT} - + {/if} @@ -593,6 +632,82 @@ manageHoverState /> + + {#if drawGeomType === MapGeomTypes.POLYGON} + + + {:else if drawGeomType === MapGeomTypes.POINT} + + + {/if} + diff --git a/src/mapper/src/lib/odk/collect.ts b/src/mapper/src/lib/odk/collect.ts index 0448db6268..4e0ed77378 100644 --- a/src/mapper/src/lib/odk/collect.ts +++ b/src/mapper/src/lib/odk/collect.ts @@ -1,21 +1,17 @@ -import type { Geometry as GeoJSONGeometry } from 'geojson'; - import { getAlertStore } from '$store/common.svelte.ts'; -import { geojsonGeomToJavarosa } from '$lib/odk/javarosa'; const alertStore = getAlertStore(); -export function openOdkCollectNewFeature(xFormId: string, geom: GeoJSONGeometry, task_id: number | null) { - if (!xFormId || !geom) { +export function openOdkCollectNewFeature(xFormId: string, entityId: string) { + if (!xFormId || !entityId) { return; } const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); - const javarosaGeom = geojsonGeomToJavarosa(geom); - if (isMobile) { - document.location.href = `odkcollect://form/${xFormId}?new_feature=${javarosaGeom}&task_id=${task_id}`; + // TODO we need to update the form to support task_id=${}& + document.location.href = `odkcollect://form/${xFormId}?feature=${entityId}`; } else { alertStore.setAlert({ variant: 'warning', diff --git a/src/mapper/src/routes/[projectId]/+page.svelte b/src/mapper/src/routes/[projectId]/+page.svelte index ef94345d1b..8ee5c279ce 100644 --- a/src/mapper/src/routes/[projectId]/+page.svelte +++ b/src/mapper/src/routes/[projectId]/+page.svelte @@ -24,7 +24,7 @@ import { getTaskStore, getTaskEventStream } from '$store/tasks.svelte.ts'; import { getEntitiesStatusStore, getEntityStatusStream, getNewBadGeomStream } from '$store/entities.svelte.ts'; import More from '$lib/components/more/index.svelte'; - import { getProjectSetupStepStore, getCommonStore } from '$store/common.svelte.ts'; + import { getProjectSetupStepStore, getCommonStore, getAlertStore } from '$store/common.svelte.ts'; import { projectSetupStep as projectSetupStepEnum } from '$constants/enums.ts'; const API_URL = import.meta.env.VITE_API_URL; @@ -45,6 +45,7 @@ const taskStore = getTaskStore(); const entitiesStore = getEntitiesStatusStore(); const commonStore = getCommonStore(); + const alertStore = getAlertStore(); const taskEventStream = getTaskEventStream(data.projectId); const entityStatusStream = getEntityStatusStream(data.projectId); @@ -159,10 +160,29 @@ newFeatureGeom = null; } - function mapNewFeatureInODK() { - const newGeom = newFeatureGeom; - cancelMapNewFeatureInODK(); - openOdkCollectNewFeature(data?.project?.odk_form_id, newGeom, taskStore.selectedTaskId); + async function mapNewFeatureInODK() { + { + /* + 1: create entity in ODK of newly created feature + 2: create geom record to show the feature on map + 3: pass entity uuid to ODK intent URL as a param + */ + } + try { + const entity = await entitiesStore.createEntity(data.projectId, { + type: 'FeatureCollection', + features: [{ type: 'Feature', geometry: newFeatureGeom, properties: {} }], + }); + await entitiesStore.createGeomRecord(data.projectId, { + status: 'NEW', + geojson: { type: 'Feature', geometry: newFeatureGeom, properties: { entity_id: entity.uuid } }, + project_id: data.projectId, + }); + cancelMapNewFeatureInODK(); + openOdkCollectNewFeature(data?.project?.odk_form_id, entity.uuid); + } catch (error) { + alertStore.setAlert({ message: 'Unable to create entity', variant: 'danger' }); + } } diff --git a/src/mapper/src/store/entities.svelte.ts b/src/mapper/src/store/entities.svelte.ts index 0a23831154..21975f3f1b 100644 --- a/src/mapper/src/store/entities.svelte.ts +++ b/src/mapper/src/store/entities.svelte.ts @@ -2,6 +2,7 @@ import { ShapeStream, Shape } from '@electric-sql/client'; import type { ShapeData } from '@electric-sql/client'; import type { Feature, FeatureCollection } from 'geojson'; import type { LngLatLike } from 'svelte-maplibre'; +import { getAlertStore } from './common.svelte'; const API_URL = import.meta.env.VITE_API_URL; @@ -42,7 +43,7 @@ type newBadGeomType = { }; let userLocationCoord: LngLatLike | undefined = $state(); -let selectedEntity: number | null = $state(null); +let selectedEntity: string | null = $state(null); let entitiesShape: Shape; let geomShape: Shape; let entitiesStatusList: entitiesStatusListType[] = $state([]); @@ -53,6 +54,9 @@ let updateEntityStatusLoading: boolean = $state(false); let selectedEntityCoordinate: entityIdCoordinateMapType | null = $state(null); let entityToNavigate: entityIdCoordinateMapType | null = $state(null); let toggleGeolocation: boolean = $state(false); +let createEntityLoading: boolean = $state(false); +let createGeomRecordLoading: boolean = $state(false); +let alertStore = getAlertStore(); function getEntityStatusStream(projectId: number): ShapeStream | undefined { if (!projectId) { @@ -160,6 +164,57 @@ function getEntitiesStatusStore() { } } + async function createEntity(projectId: number, payload: Record) { + try { + createEntityLoading = true; + const resp = await fetch(`${import.meta.env.VITE_API_URL}/projects/${projectId}/create-entity`, { + method: 'POST', + body: JSON.stringify(payload), + headers: { + 'Content-type': 'application/json', + }, + credentials: 'include', + }); + if (!resp.ok) { + const errorData = await resp.json(); + throw new Error(errorData.detail); + } + return await resp.json(); + } catch (error: any) { + alertStore.setAlert({ + variant: 'danger', + message: error.message || 'Failed to create entity', + }); + } finally { + createEntityLoading = false; + } + } + + async function createGeomRecord(projectId: number, payload: Record) { + try { + createGeomRecordLoading = true; + const resp = await fetch(`${import.meta.env.VITE_API_URL}/projects/${projectId}/geometry/records`, { + method: 'POST', + body: JSON.stringify(payload), + headers: { + 'Content-type': 'application/json', + }, + credentials: 'include', + }); + if (!resp.ok) { + const errorData = await resp.json(); + throw new Error(errorData.detail); + } + } catch (error: any) { + alertStore.setAlert({ + variant: 'danger', + message: error.message || 'Failed to create geometry record', + }); + } finally { + createGeomRecordLoading = false; + } + } + function setEntityToNavigate(entityCoordinate: entityIdCoordinateMapType | null) { entityToNavigate = entityCoordinate; } @@ -177,6 +232,8 @@ function getEntitiesStatusStore() { setSelectedEntity: setSelectedEntity, syncEntityStatus: syncEntityStatus, updateEntityStatus: updateEntityStatus, + createEntity: createEntity, + createGeomRecord: createGeomRecord, setSelectedEntityCoordinate: setSelectedEntityCoordinate, setEntityToNavigate: setEntityToNavigate, setToggleGeolocation: setToggleGeolocation,