diff --git a/src/frontend/src/components/ProjectDetailsV2/FeatureSelectionPopup.tsx b/src/frontend/src/components/ProjectDetailsV2/FeatureSelectionPopup.tsx index 3cd2ce1a37..cbd2c91dbf 100644 --- a/src/frontend/src/components/ProjectDetailsV2/FeatureSelectionPopup.tsx +++ b/src/frontend/src/components/ProjectDetailsV2/FeatureSelectionPopup.tsx @@ -19,7 +19,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 ( @@ -36,7 +38,9 @@ const FeatureSelectionPopup = ({ featureProperties, taskId }: FeatureSelectionPo } fmtm-rounded-t-2xl md:fmtm-rounded-tr-none md:fmtm-rounded-l-2xl`} >
-

Feature: {featureProperties?.osm_id}

+

+ {entity?.osm_id ? `Feature: ${entity.osm_id}` : `Entity: ${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 && (
diff --git a/src/frontend/src/store/types/ITask.ts b/src/frontend/src/store/types/ITask.ts index 89875694b4..690622e501 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/utilfunctions/getTaskStatusStyle.ts b/src/frontend/src/utilfunctions/getTaskStatusStyle.ts index 6edb289658..c18b0f0551 100644 --- a/src/frontend/src/utilfunctions/getTaskStatusStyle.ts +++ b/src/frontend/src/utilfunctions/getTaskStatusStyle.ts @@ -2,8 +2,7 @@ import { Fill, Icon, Stroke, Style } from 'ol/style'; import { getCenter } from 'ol/extent'; import { Point } from 'ol/geom'; import AssetModules from '@/shared/AssetModules'; -import { entity_state } from '@/types/enums'; -import { EntityOsmMap } from '@/store/types/IProject'; +import { Text } from 'ol/style'; function createPolygonStyle(fillColor: string, strokeColor: string) { return new Style({ @@ -106,11 +105,7 @@ const getTaskStatusStyle = (feature: Record, mapTheme: Record, entityOsmMap: EntityOsmMap[]) => { - const entity = entityOsmMap?.find((entity) => entity?.osm_id === osmId) as EntityOsmMap; - - let status = entity_state[entity?.status]; - +export const getFeatureStatusStyle = (mapTheme: Record, status: string, osm_id: number) => { const borderStrokeColor = 'rgb(0,0,0,0.5)'; const strokeStyle = new Stroke({ @@ -125,6 +120,10 @@ export const getFeatureStatusStyle = (osmId: string, mapTheme: Record { useDocumentTitle('Project Details'); @@ -71,6 +73,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); @@ -512,12 +515,36 @@ const ProjectDetailsV2 = () => { zIndex={5} style="" /> + { + const entity = entityOsmMap?.find( + (entity) => entity?.id === feature?.getProperties()?.entity_id, + ) as EntityOsmMap; + const status = entity_state[entity?.status]; + return getFeatureStatusStyle(mapTheme, status, entity?.osm_id); + }} + /> {dataExtractUrl && isValidUrl(dataExtractUrl) && dataExtractExtent && selectedTask && ( { - return getFeatureStatusStyle(feature?.getProperties()?.osm_id, mapTheme, entityOsmMap); + const osmId = feature?.getProperties()?.osm_id; + const entity = entityOsmMap?.find((entity) => entity?.osm_id === osmId) as EntityOsmMap; + + const status = entity_state[entity?.status]; + return getFeatureStatusStyle(mapTheme, status, entity?.osm_id); }} viewProperties={{ size: map?.getSize(), diff --git a/src/mapper/src/lib/components/map/main.svelte b/src/mapper/src/lib/components/map/main.svelte index f7aa006825..9d06a0ae5c 100644 --- a/src/mapper/src/lib/components/map/main.svelte +++ b/src/mapper/src/lib/components/map/main.svelte @@ -155,6 +155,9 @@ const clickedTaskFeature = map?.queryRenderedFeatures(e.point, { layers: ['task-fill-layer'], }); + const clickedNewEntityFeature = map?.queryRenderedFeatures(e.point, { + layers: ['new-entity-polygon-layer'], + }); // if clicked point contains entity then set it's osm id else set null to store if (clickedEntityFeature && clickedEntityFeature?.length > 0) { const entityCentroid = centroid(clickedEntityFeature[0].geometry); @@ -164,6 +167,14 @@ 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, + coordinate: entityCentroid?.geometry?.coordinates, + }); } else { entitiesStore.setSelectedEntity(null); entitiesStore.setSelectedEntityCoordinate(null); @@ -172,15 +183,18 @@ // if clicked point contains task layer if (clickedTaskFeature && clickedTaskFeature?.length > 0) { taskAreaClicked = true; - const clickedTaskId = clickedTaskFeature[0]?.properties?.fid; - taskStore.setSelectedTaskId(clickedTaskId, clickedTaskFeature[0]?.properties?.task_index); + const clickedTaskId = clickedTaskFeature?.[0]?.properties?.fid; + taskStore.setSelectedTaskId(clickedTaskId, clickedTaskFeature?.[0]?.properties?.task_index); if (+(projectSetupStepStore.projectSetupStep || 0) === projectSetupStepEnum['task_selection']) { localStorage.setItem(`project-${projectId}-setup`, projectSetupStepEnum['complete_setup']); projectSetupStepStore.setProjectSetupStep(projectSetupStepEnum['complete_setup']); } } - 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'); @@ -267,21 +281,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() { @@ -487,7 +522,7 @@ extent={taskStore.selectedTaskGeom} extractGeomCols={true} promoteId="id" - processGeojson={(geojsonData) => addStatusToGeojsonProperty(geojsonData)} + processGeojson={(geojsonData) => addStatusToGeojsonProperty(geojsonData, '')} geojsonUpdateDependency={entitiesStore.entitiesStatusList} > {/if} - + {#if showEntityLayer} + + + + + {/if} @@ -624,7 +708,7 @@ (selectedStyleUrl = style)} diff --git a/src/mapper/src/lib/odk/collect.ts b/src/mapper/src/lib/odk/collect.ts index eab0c8769f..56ac972849 100644 --- a/src/mapper/src/lib/odk/collect.ts +++ b/src/mapper/src/lib/odk/collect.ts @@ -5,18 +5,16 @@ 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) { // TODO we need to update the form to support task_id=${}& - document.location.href = `odkcollect://form/${xFormId}?new_feature=${javarosaGeom}&task_id=${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 6322a9e8df..c26f55af6c 100644 --- a/src/mapper/src/routes/[projectId]/+page.svelte +++ b/src/mapper/src/routes/[projectId]/+page.svelte @@ -23,7 +23,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'; import type { ShapeStream } from '@electric-sql/client'; @@ -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); @@ -114,15 +115,17 @@ }); $effect(() => { + entitiesStore.syncEntityStatus(data.projectId); + }); + + $effect(() => { + entitiesStore.entitiesList; let entityStatusStream: ShapeStream | undefined; + if (entitiesStore.entitiesList?.length === 0) return; async function getEntityStatus() { - const entityStatusResponse = await fetch(`${API_URL}/projects/${data.projectId}/entities/statuses`, { - credentials: 'include', - }); - const response = await entityStatusResponse.json(); entityStatusStream = getEntityStatusStream(data.projectId); - await entitiesStore.subscribeToEntityStatusUpdates(entityStatusStream, response); + await entitiesStore.subscribeToEntityStatusUpdates(entityStatusStream, entitiesStore.entitiesList); } getEntityStatus(); @@ -159,10 +162,30 @@ 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, + }); + entitiesStore.syncEntityStatus(data.projectId); + cancelMapNewFeatureInODK(); + openOdkCollectNewFeature(data?.project?.odk_form_id, entity.uuid); + } catch (error) { + alertStore.setAlert({ message: 'Unable to create entity', variant: 'danger' }); + } } @@ -229,6 +252,7 @@ tabindex="0" size="small" class="primary w-fit" + loading={entitiesStore.createEntityLoading || entitiesStore.createGeomRecordLoading} > PROCEED diff --git a/src/mapper/src/store/entities.svelte.ts b/src/mapper/src/store/entities.svelte.ts index 70898d008a..8bf6797b94 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; @@ -53,6 +54,10 @@ 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 entitiesList: entitiesListType[] = $state([]); +let alertStore = getAlertStore(); function getEntityStatusStream(projectId: number): ShapeStream | undefined { if (!projectId) { @@ -119,7 +124,7 @@ function getEntitiesStatusStore() { }); } - async function setSelectedEntity(entityOsmId: number | null) { + async function setSelectedEntity(entityOsmId: string | null) { selectedEntity = entityOsmId; } @@ -130,9 +135,11 @@ function getEntitiesStatusStore() { async function syncEntityStatus(projectId: number) { try { syncEntityStatusLoading = true; - await fetch(`${API_URL}/projects/${projectId}/entities/statuses`, { + const entityStatusResponse = await fetch(`${API_URL}/projects/${projectId}/entities/statuses`, { credentials: 'include', }); + const response = await entityStatusResponse.json(); + entitiesList = response; syncEntityStatusLoading = false; } catch (error) { syncEntityStatusLoading = false; @@ -156,6 +163,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; } @@ -173,6 +231,8 @@ function getEntitiesStatusStore() { setSelectedEntity: setSelectedEntity, syncEntityStatus: syncEntityStatus, updateEntityStatus: updateEntityStatus, + createEntity: createEntity, + createGeomRecord: createGeomRecord, setSelectedEntityCoordinate: setSelectedEntityCoordinate, setEntityToNavigate: setEntityToNavigate, setToggleGeolocation: setToggleGeolocation, @@ -208,6 +268,15 @@ function getEntitiesStatusStore() { get userLocationCoord() { return userLocationCoord; }, + get createEntityLoading() { + return createEntityLoading; + }, + get createGeomRecordLoading() { + return createGeomRecordLoading; + }, + get entitiesList() { + return entitiesList; + }, }; }