Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
3ba9051
Initial input setup and loading saved feature
latin-panda Oct 16, 2025
43df2b4
Adds watch to current location function
latin-panda Oct 17, 2025
76b89de
Merge branch 'main' of https://github.com/getodk/web-forms into add-g…
latin-panda Oct 24, 2025
c5c479a
maps and placement-maps interactions
latin-panda Oct 29, 2025
8052234
Nicer code
latin-panda Oct 29, 2025
a0cd16e
fixes changeset
latin-panda Oct 29, 2025
3734ee0
rename function
latin-panda Oct 29, 2025
f1858ee
Extract code a new mapFeatures module
latin-panda Oct 30, 2025
74f5bdf
adds debounce to watch location
latin-panda Oct 30, 2025
ba7efef
feedback and unit test
latin-panda Oct 30, 2025
4991c04
fixes button style
latin-panda Oct 30, 2025
5a52825
updates save button icon
latin-panda Oct 30, 2025
50fa15e
improves test form for geopoint with maps
latin-panda Oct 31, 2025
9ac956c
fixes styles in mobile
latin-panda Oct 31, 2025
851a735
Adds toast message
latin-panda Oct 31, 2025
4045789
shows overlay for location mode when there's an error
latin-panda Oct 31, 2025
3feca4f
should not center on subsequent location captures - on watch location
latin-panda Oct 31, 2025
cc522c3
feedback labels for desktop and mobile
latin-panda Nov 3, 2025
21f35f4
Fixes view center experience
latin-panda Nov 3, 2025
639ab42
Fixes view center experience
latin-panda Nov 3, 2025
d41ead9
Update layer while animating
latin-panda Nov 4, 2025
fc0134b
test coverage
latin-panda Nov 4, 2025
8b819f9
updates feature matrix
latin-panda Nov 4, 2025
f4b0cdc
updates visual test
latin-panda Nov 4, 2025
3e9b267
Don't center if feature is in view and not displaying properties
latin-panda Nov 5, 2025
f85e723
Don't center if feature is in view and not displaying properties
latin-panda Nov 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/true-snakes-hear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@getodk/web-forms': minor
---

Adds support for Geopoint with "maps" and "placement-map" appearances.
75 changes: 75 additions & 0 deletions packages/common/src/fixtures/geopoint/geopoint-map.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?xml version="1.0"?>
<h:html xmlns="http://www.w3.org/2002/xforms" xmlns:h="http://www.w3.org/1999/xhtml"
xmlns:ev="http://www.w3.org/2001/xml-events" xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:jr="http://openrosa.org/javarosa" xmlns:orx="http://openrosa.org/xforms"
xmlns:odk="http://www.opendatakit.org/xforms">
<h:head>
<h:title>Geopoint</h:title>
<model odk:xforms-version="1.0.0">
<itext>
<translation lang="English (en)">
<text id="/data/facility_gps:label">
<value>Where are you?</value>
</text>
<text id="/data/facility_age:label">
<value>Your age</value>
</text>
<text id="/data/facility_gps_2:label">
<value>Where are you again?</value>
</text>
</translation>
<translation lang="French (fr)">
<text id="/data/facility_gps:label">
<value>Où es-tu?</value>
</text>
<text id="/data/facility_age:label">
<value>Ton âge</value>
</text>
<text id="/data/facility_gps_2:label">
<value>Où es-tu encore?</value>
</text>
</translation>
</itext>
<instance>
<data id="1_geopoint_relevant_disabled" version="2025020401">
<facility_gps>40.7128 -74.0060 100 5</facility_gps>
<facility_age/>
<facility_gps_2/>
<facility_gps_placement>40.7128 -74.0060 100 5</facility_gps_placement>
<facility_age_placement/>
<facility_gps_2_placement/>
<meta>
<instanceID/>
</meta>
</data>
</instance>
<bind nodeset="/data/facility_gps" type="geopoint" required="true()"/>
<bind nodeset="/data/facility_age" type="int"/>
<bind nodeset="/data/facility_gps_2" type="geopoint" relevant=" /data/facility_age &gt; 0" readonly=" /data/facility_age &gt; 5"/>
<bind nodeset="/data/facility_gps_placement" type="geopoint" required="true()"/>
<bind nodeset="/data/facility_age_placement" type="int"/>
<bind nodeset="/data/facility_gps_2_placement" type="geopoint" relevant=" /data/facility_age &gt; 0" readonly=" /data/facility_age &gt; 5"/>
<bind nodeset="/data/meta/instanceID" type="string" readonly="true()" jr:preload="uid"/>
</model>
</h:head>
<h:body>
<input ref="/data/facility_gps" appearance="maps">
<label ref="jr:itext('/data/facility_gps:label')"/>
</input>
<input ref="/data/facility_age">
<label ref="jr:itext('/data/facility_age:label')"/>
</input>
<input ref="/data/facility_gps_2" appearance="maps">
<label ref="jr:itext('/data/facility_gps_2:label')"/>
</input>
<input ref="/data/facility_gps_placement" appearance="placement-map">
<label ref="jr:itext('/data/facility_gps:label')"/>
</input>
<input ref="/data/facility_age_placement">
<label ref="jr:itext('/data/facility_age:label')"/>
</input>
<input ref="/data/facility_gps_2_placement" appearance="placement-map">
<label ref="jr:itext('/data/facility_gps_2:label')"/>
</input>
</h:body>
</h:html>
25 changes: 25 additions & 0 deletions packages/web-forms/src/assets/images/location-icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 13 additions & 3 deletions packages/web-forms/src/components/common/map/AsyncMap.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import type { SelectItem } from '@getodk/xforms-engine';
import ProgressSpinner from 'primevue/progressspinner';
import { computed, type DefineComponent, onMounted, shallowRef } from 'vue';
import type { Mode } from '@/components/common/map/getModeConfig.ts';
import {
createFeatureCollectionAndProps,
type Feature,
Expand All @@ -15,14 +16,15 @@ import {
type MapBlockComponent = DefineComponent<{
featureCollection: { type: string; features: Feature[] };
disabled: boolean;
mode: Mode;
orderedExtraProps: Map<string, Array<[key: string, value: string]>>;
savedFeatureValue: string | undefined;
savedFeatureValue: Feature | undefined;
}>;

interface AsyncMapProps {
// ToDo: Expand typing when implementing Geo Point/Shape/Trace question types.
features: readonly SelectItem[];
features?: readonly SelectItem[];
disabled: boolean;
mode: Mode;
savedFeatureValue: string | undefined;
}

Expand All @@ -38,6 +40,13 @@ const STATES = {
const mapComponent = shallowRef<MapBlockComponent | null>(null);
const currentState = shallowRef<(typeof STATES)[keyof typeof STATES]>(STATES.LOADING);
const featureCollectionAndProps = computed(() => createFeatureCollectionAndProps(props.features));
const savedFeatureValue = computed(() => {
if (!props.savedFeatureValue) {
return;
}
const { featureCollection } = createFeatureCollectionAndProps([props.savedFeatureValue]);
return featureCollection.features?.[0];
});

const loadMap = async () => {
currentState.value = STATES.LOADING;
Expand Down Expand Up @@ -74,6 +83,7 @@ onMounted(loadMap);
:is="mapComponent"
v-else
:feature-collection="featureCollectionAndProps.featureCollection"
:mode="mode"
:ordered-extra-props="featureCollectionAndProps.orderedExtraPropsMap"
:saved-feature-value="savedFeatureValue"
:disabled="disabled"
Expand Down
93 changes: 64 additions & 29 deletions packages/web-forms/src/components/common/map/MapBlock.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,21 @@
* load on demand. Avoids main bundle bloat.
*/
import IconSVG from '@/components/common/IconSVG.vue';
import type { Mode } from '@/components/common/map/getModeConfig.ts';
import MapProperties from '@/components/common/map/MapProperties.vue';
import MapStatusBar from '@/components/common/map/MapStatusBar.vue';
import { useMapBlock } from '@/components/common/map/useMapBlock.ts';
import { STATES, useMapBlock } from '@/components/common/map/useMapBlock.ts';
import { QUESTION_HAS_ERROR } from '@/lib/constants/injection-keys.ts';
import type { FeatureCollection } from 'geojson';
import type { FeatureCollection, Feature } from 'geojson';
import Button from 'primevue/button';
import { computed, type ComputedRef, inject, onMounted, onUnmounted, ref, watch } from 'vue';

interface MapBlockProps {
featureCollection: FeatureCollection;
disabled: boolean;
mode: Mode;
orderedExtraProps: Map<string, Array<[string, string]>>;
savedFeatureValue: string | undefined;
savedFeatureValue: Feature | undefined;
}

const props = defineProps<MapBlockProps>();
Expand All @@ -28,41 +31,36 @@ const showErrorStyle = inject<ComputedRef<boolean>>(
computed(() => false)
);

const mapHandler = useMapBlock();
const mapHandler = useMapBlock(props.mode, () => emitSavedFeature());

onMounted(() => {
if (!mapElement.value || !mapHandler) {
return;
}

mapHandler.initializeMap(mapElement.value, props.featureCollection);
mapHandler.toggleClickBinding(!props.disabled);
mapHandler.setSavedByValueProp(props.savedFeatureValue);
mapHandler.initMap(mapElement.value, props.featureCollection, props.savedFeatureValue);
mapHandler.setupMapInteractions(props.disabled);
document.addEventListener('keydown', handleEscapeKey);
});

onUnmounted(() => {
document.removeEventListener('keydown', handleEscapeKey);
mapHandler.teardownMap();
});

watch(
() => props.featureCollection,
(newData) => {
mapHandler.loadGeometries(newData);
mapHandler.setSavedByValueProp(props.savedFeatureValue);
},
(newData) => mapHandler.updateFeatureCollection(newData, props.savedFeatureValue),
{ deep: true }
);

watch(
() => props.savedFeatureValue,
(newSaved) => mapHandler.setSavedByValueProp(newSaved)
);
watch(() => props.savedFeatureValue, mapHandler.setSavedByValueProp);

watch(
() => props.disabled,
(newValue) => mapHandler.toggleClickBinding(!newValue)
);
watch(() => props.disabled, mapHandler.setupMapInteractions);

const emitSavedFeature = () => {
emit('save', mapHandler.savedFeature.value?.getProperties()?.odk_value);
};

const handleEscapeKey = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isFullScreen.value) {
Expand All @@ -72,7 +70,7 @@ const handleEscapeKey = (event: KeyboardEvent) => {

const saveSelection = () => {
mapHandler.saveFeature();
emit('save', mapHandler.savedFeature.value?.getProperties()?.odk_value);
emitSavedFeature();
};

const discardSavedFeature = () => {
Expand All @@ -86,33 +84,58 @@ const discardSavedFeature = () => {
<div :class="{ 'map-container': true, 'map-full-screen': isFullScreen }">
<div class="control-bar">
<!-- TODO: translations -->
<button :class="{ 'control-active': isFullScreen }" title="Full Screen" @click="isFullScreen = !isFullScreen">
<button
:class="{ 'control-active': isFullScreen }"
title="Full Screen"
@click="isFullScreen = !isFullScreen"
>
<IconSVG name="mdiArrowExpandAll" size="sm" />
</button>
<!-- TODO: translations -->
<button title="Zoom to fit all options" :disabled="!props.featureCollection?.features?.length" @click="mapHandler.fitToAllFeatures">
<button
title="Zoom to fit all options"
:disabled="!mapHandler.canFitToAllFeatures()"
@click="mapHandler.fitToAllFeatures"
>
<IconSVG name="mdiFullscreen" />
</button>
<!-- TODO: translations -->
<button title="Zoom to current location" @click="mapHandler.centerCurrentLocation">
<button title="Zoom to current location" @click="mapHandler.watchCurrentLocation">
<IconSVG name="mdiCrosshairsGps" size="sm" />
</button>
</div>

<div ref="mapElement" class="map-block" />
<div ref="mapElement" class="map-block">
<div ref="mapElement" />

<div v-if="mapHandler.shouldShowMapOverlay()" class="map-overlay">
<Button outlined severity="contrast" @click="mapHandler.watchCurrentLocation">
<IconSVG name="mdiCrosshairsGps" />
<!-- TODO: translations -->
<span>Get location</span>
</Button>
</div>
</div>

<MapStatusBar
:has-saved-feature="!!mapHandler.savedFeature.value"
:is-feature-saved="!!mapHandler.savedFeature.value"
:is-capturing="mapHandler.currentState.value === STATES.CAPTURING"
class="map-status-bar-component"
:can-remove="!disabled && mapHandler.canRemoveCurrentLocation()"
:can-save="!disabled && mapHandler.canSaveCurrentLocation()"
:can-view-details="mapHandler.canViewProperties()"
@discard="discardSavedFeature"
@save="saveSelection"
@view-details="mapHandler.selectSavedFeature()"
/>

<MapProperties
v-if="mapHandler.selectedFeatureProperties.value"
:reserved-props="mapHandler.selectedFeatureProperties.value"
v-if="mapHandler.canViewProperties() && mapHandler.selectedFeatureProperties.value"
:can-remove="!disabled"
:can-save="!disabled"
:is-feature-saved="mapHandler.isSelectedFeatureSaved()"
:ordered-extra-props="orderedExtraProps"
:has-saved-feature="mapHandler.isSelectedFeatureSaved()"
:disabled="disabled"
:reserved-props="mapHandler.selectedFeatureProperties.value"
@close="mapHandler.unselectFeature()"
@discard="discardSavedFeature"
@save="saveSelection"
Expand Down Expand Up @@ -152,10 +175,22 @@ const discardSavedFeature = () => {
overflow: hidden;

.map-block {
position: relative;
background: var(--odk-base-background-color);
width: 100%;
height: 445px;
}

.map-overlay {
position: absolute;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
background-color: rgba(from var(--odk-muted-background-color) r g b / 0.9);
z-index: var(--odk-z-index-overlay);
}
}

.map-container.map-full-screen {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import { computed } from 'vue';
const props = defineProps<{
reservedProps: Record<string, string>;
orderedExtraProps: Map<string, Array<[key: string, value: string]>>;
hasSavedFeature: boolean;
disabled: boolean;
isFeatureSaved: boolean;
canRemove: boolean;
canSave: boolean;
}>();

const emit = defineEmits(['close', 'save', 'discard']);
Expand All @@ -32,12 +33,12 @@ const orderedProps = computed(() => {
</dl>

<div class="map-properties-footer">
<Button v-if="hasSavedFeature && !disabled" outlined severity="contrast" @click="emit('discard')">
<Button v-if="isFeatureSaved && canRemove" outlined severity="contrast" @click="emit('discard')">
<span>–</span>
<!-- TODO: translations -->
<span>Remove selection</span>
</Button>
<Button v-if="!hasSavedFeature && !disabled" @click="emit('save')">
<Button v-if="!isFeatureSaved && canSave" @click="emit('save')">
<IconSVG name="mdiCheck" size="sm" variant="inverted" />
<!-- TODO: translations -->
<span>Save selected</span>
Expand Down
Loading
Loading