diff --git a/.changeset/four-steaks-try.md b/.changeset/four-steaks-try.md new file mode 100644 index 000000000..aab9abe7f --- /dev/null +++ b/.changeset/four-steaks-try.md @@ -0,0 +1,8 @@ +--- +'@getodk/xforms-engine': minor +'@getodk/web-forms': minor +'@getodk/scenario': patch +'@getodk/common': patch +--- + +Support select one from map diff --git a/packages/common/src/fixtures/select/geodataCsv.csv b/packages/common/src/fixtures/select/geodataCsv.csv new file mode 100644 index 000000000..181afdda9 --- /dev/null +++ b/packages/common/src/fixtures/select/geodataCsv.csv @@ -0,0 +1,10 @@ +name,label,geometry,population_size,num_hospitals +1,Lisbon,38.7 -9.15,500000,120 +2,Dublin,53.33 -6.25,1200000,200 +3,Edinburgh,55.953251 -3.188267,500000,150 +4,Amsterdam-Brussels-Luxembourg,52.37 4.9; 50.85 4.35; 49.6 6.13,3000000,450 +5,Zurich-Geneva-Basel,47.3667 8.55; 46.2022 6.1457; 47.55839 7.57327,1500000,300 +6,Barcelona-Madrid,41.390205 2.154007; 40.38 -3.7,5000000,600 +7,London-Paris-Amsterdam-London,51.5 -0.12; 48.85 2.35; 52.37 4.9; 51.5 -0.12,20000000,1000 +8,Berlin-Frankfurt-Munich-Berlin,52.52 13.4; 50.11552 8.68417; 48.13743 11.57549; 52.52 13.4,7000000,800 +9,Vienna-Salzburg-Innsbruck-Vienna,48.2 16.37; 47.79941 13.04399; 47.26266 11.39454; 48.2 16.37,3000000,400 diff --git a/packages/common/src/fixtures/select/geodataGeoJson.geojson b/packages/common/src/fixtures/select/geodataGeoJson.geojson new file mode 100644 index 000000000..ac5da0a89 --- /dev/null +++ b/packages/common/src/fixtures/select/geodataGeoJson.geojson @@ -0,0 +1,251 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -73.56, + 45.52 + ] + }, + "properties": { + "nurses": 19, + "doctors": 10, + "patients": 390, + "id": "fea-07", + "title": "Chinatown Medical Center" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -73.585, + 45.5 + ] + }, + "properties": { + "nurses": 23, + "doctors": 13, + "patients": 550, + "id": "fea-08", + "title": "West End Health Clinic" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -73.555, + 45.485 + ] + }, + "properties": { + "nurses": 16, + "doctors": 6, + "patients": 300, + "id": "fea-09", + "title": "Verdun Community Clinic" + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -73.59, + 45.53 + ], + [ + -73.58, + 45.53 + ], + [ + -73.57, + 45.53 + ] + ] + }, + "properties": { + "id": "fea-018", + "title": "Canada Goose", + "quantities": 4000, + "migration_days": 12, + "start_date": "2024-09-10", + "end_date": "2024-09-22" + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -73.59, + 45.47 + ], + [ + -73.58, + 45.47 + ], + [ + -73.57, + 45.47 + ] + ] + }, + "properties": { + "id": "fea-019", + "title": "Snowy Owl", + "quantities": 150, + "migration_days": 8, + "start_date": "2024-11-15", + "end_date": "2024-11-23" + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -73.6, + 45.5 + ], + [ + -73.6, + 45.51 + ], + [ + -73.6, + 45.52 + ] + ] + }, + "properties": { + "id": "fea-020", + "title": "Peregrine Falcon", + "quantities": 300, + "migration_days": 5, + "start_date": "2024-10-01", + "end_date": "2024-10-06" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + -73.59, + 45.54 + ], + [ + -73.58, + 45.54 + ], + [ + -73.58, + 45.55 + ], + [ + -73.59, + 45.55 + ], + [ + -73.59, + 45.54 + ] + ] + ] + }, + "properties": { + "id": "fea-021", + "title": "Montreal Urban Farm 1", + "size": "200 hectares", + "harvest_quantity": "400 tons", + "harvest_date": "2024-09-05" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + -73.61, + 45.46 + ], + [ + -73.6, + 45.46 + ], + [ + -73.6, + 45.47 + ], + [ + -73.61, + 45.47 + ], + [ + -73.61, + 45.46 + ] + ] + ] + }, + "properties": { + "id": "fea-022", + "title": "Montreal Urban Farm 2", + "size": "150 hectares", + "harvest_quantity": "300 tons", + "harvest_date": "2024-10-10" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + -73.54, + 45.53 + ], + [ + -73.53, + 45.53 + ], + [ + -73.53, + 45.54 + ], + [ + -73.54, + 45.54 + ], + [ + -73.54, + 45.53 + ] + ] + ] + }, + "properties": { + "id": "fea-023", + "title": "Montreal Urban Farm 3", + "size": "250 hectares", + "harvest_quantity": "500 tons", + "harvest_date": "2024-08-15" + } + } + ] +} diff --git a/packages/common/src/fixtures/select/select-one-from-map-external-files.xml b/packages/common/src/fixtures/select/select-one-from-map-external-files.xml new file mode 100644 index 000000000..731e9337a --- /dev/null +++ b/packages/common/src/fixtures/select/select-one-from-map-external-files.xml @@ -0,0 +1,124 @@ + + + + select-one-from-map-external-files + + + + + + + + + + + + + + + + + + + 1 + Madrid + 40.446254412914385 -3.7445467750577563 + + + 2 + Warsaw + 52.18544754541023 21.1006145352125 + + + 3 + Stockholm-Copenhagen-Berlin + 59.31284185390564 18.04203801493341; 55.7237702447557 12.59922155107742; 52.48574101841746 + 13.337427033540052 + + + + 4 + Prague-Vienna-Budapest + 47.485570717616696 19.04921763281365; 48.19954394662233 16.42105774490628; 50.139932923435055 + 14.417694964430638 + + + + 5 + London-Leeds-Liverpool-London + 53.40903732686215 -2.991905620523312; 51.505094241512666 -0.1321803079139272; 53.8052722823615 + -1.5363611594108022; 53.40903732686215 -2.991905620523312 + + + + 6 + Paris-Basel-Frankfurt-Paris + 48.840915452897946 2.3146557958955327; 47.54579786412256 7.63542665300713; 50.10149750150822 + 8.703168961933983; 48.840915452897946 2.3146557958955327 + + + + + + + + + + + + + + + + Do you want to show the select with geojson file? + + yes + Yes + + + no + No + + + + + Select one from geojson file + + + + + + + + + Do you want to disable the select with csv file? + + yes + Yes + + + no + No + + + + + Select one from csv file + + + + + + + + Select one from xml file + + + + + + + diff --git a/packages/scenario/src/jr/select/SelectChoice.ts b/packages/scenario/src/jr/select/SelectChoice.ts index 2d0705ee5..c49deea56 100644 --- a/packages/scenario/src/jr/select/SelectChoice.ts +++ b/packages/scenario/src/jr/select/SelectChoice.ts @@ -1,10 +1,9 @@ import type { SelectItem } from '@getodk/xforms-engine'; import { ComparableChoice } from '../../choice/ComparableChoice.ts'; -import { UnclearApplicabilityError } from '../../error/UnclearApplicabilityError.ts'; -import type { SelectChoiceArbitraryChildList } from './SelectChoiceArbitraryChildList.ts'; export class SelectChoice extends ComparableChoice { readonly value: string; + readonly properties: Array<[string, string]>; get label(): string { return this.selectItem.label.asString; @@ -14,37 +13,11 @@ export class SelectChoice extends ComparableChoice { super(); this.value = selectItem.value; + this.properties = selectItem.properties; } - /** - * **PORTING NOTES** - * - * This was - * {@link https://github.com/getodk/javarosa/pull/660 | introduced in JavaRosa to support geo functionality}. - * The method may access a well-known `geometry` child (or any arbitrary - * child) by name, - * {@link https://github.com/getodk/web-forms/pull/110#discussion_r1610611805 | where}… - * - * > Could be a repeat, a secondary instance or an external secondary instance - * > (csv, xml or geojson). The `map` appearance that uses the `geometry` - * > child for mapping works identically for all of those. - * - * @todo This is currently unimplemented (and will cause failures in tests - * accessing it, even if other functionality is introduced to unblock code - * paths leading to its access) until we deem it appropriate to address. - * Addressing it will likely involve understanding how it relates to and/or - * affected tests can be expressed at an integration level. - */ - getChild(_childName: string): string | null { - throw new UnclearApplicabilityError( - 'select choice: getting arbitrary child of secondary instance nodes by name' - ); - } - - getAdditionalChildren(): SelectChoiceArbitraryChildList { - throw new UnclearApplicabilityError( - `select choice: getting arbitrary children of secondary instance nodes` - ); + getProperties(): Array<[string, string]> { + return this.properties; } getValue(): string { diff --git a/packages/scenario/src/jr/select/SelectChoiceArbitraryChildList.ts b/packages/scenario/src/jr/select/SelectChoiceArbitraryChildList.ts deleted file mode 100644 index 850eb2025..000000000 --- a/packages/scenario/src/jr/select/SelectChoiceArbitraryChildList.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { JavaUtilList } from '../../java/util/List.ts'; - -export interface SelectChoiceArbitraryChildList extends JavaUtilList {} diff --git a/packages/scenario/test/select.test.ts b/packages/scenario/test/select.test.ts index 090ed3cd0..8bee275f3 100644 --- a/packages/scenario/test/select.test.ts +++ b/packages/scenario/test/select.test.ts @@ -26,7 +26,6 @@ import { setUpSimpleReferenceManager } from '../src/jr/reference/ReferenceManage import { r } from '../src/jr/resource/ResourcePathHelper.ts'; import type { SelectChoice } from '../src/jr/select/SelectChoice.ts'; import { ANSWER_REQUIRED_BUT_EMPTY } from '../src/jr/validation/ValidateOutcome.ts'; -import { nullValue } from '../src/value/ExpectedNullValue.ts'; // Ported as of https://github.com/getodk/javarosa/commit/5ae68946c47419b83e7d28290132d846e457eea6 describe('DynamicSelectUpdateTest.java', () => { @@ -624,165 +623,62 @@ describe('SelectChoiceTest.java', () => { /** * **PORTING NOTES** * - * The tests in this sub-suite are currently blocked by several absent features: + * Some tests in this suite are blocked by the absence of support for repeat-based itemsets. * - * 1. Retrieving external secondary instance resources - * 2. Support for external secondary instance resources when evaluating - * XPath expressions referencing them - * 3. Any notion of engine API access to the well-known GeoJSON `geometry` - * property, or any other arbitrary named child nodes present in any - * secondary instance (whether external or otherwise) */ describe('`getChild`', () => { - it.fails('returns named child when choices are from secondary instance', async () => { + it('returns properties in order when choices are from secondary instance', async () => { setUpSimpleReferenceManager(r('external-select-geojson.xml').getParent(), 'file'); const scenario = await Scenario.init('external-select-geojson.xml'); - expect(scenario.choicesOf('/data/q').get(1)?.getChild('geometry')).toBe('0.5 104 0 0'); - expect(scenario.choicesOf('/data/q').get(1)?.getChild('special-property')).toBe( - 'special value' - ); - }); - - it.fails( - 'returns null when choices are from secondary instance and requested child does not exist', - async () => { - setUpSimpleReferenceManager(r('external-select-geojson.xml').getParent(), 'file'); - - const scenario = await Scenario.init('external-select-geojson.xml'); - - expect(scenario.choicesOf('/data/q').get(1)?.getChild('non-existent')).toBe(null); - } - ); - - it.fails( - 'returns empty string when choices are from secondary instance and requested child has no value', - async () => { - const scenario = await Scenario.init( - 'Select with empty value', - html( - head( - title('Select with empty value'), - model( - mainInstance(t("data id='select-empty'", t('select'))), - instance('choices', t('item', t('label', 'Item'), t('property', ''))) - ) - ), - body(select1Dynamic('/data/select', "instance('choices')/root/item", 'name', 'label')) - ) - ); + const firstChoiceProperties = scenario.choicesOf('/data/q').get(0)?.getProperties(); + expect(firstChoiceProperties?.length).toEqual(4); + expect(firstChoiceProperties).deep.equal([ + ['geometry', '0.5 102 0 0'], + ['id', 'fs87b'], + ['name', 'My cool point'], + ['foo', 'bar'], + ]); - expect(scenario.choicesOf('/data/select').get(0)?.getChild('property')).toBe(''); - } - ); + const secondChoiceProperties = scenario.choicesOf('/data/q').get(1)?.getProperties(); + expect(secondChoiceProperties?.length).toEqual(5); + expect(scenario.choicesOf('/data/q').get(1)?.getProperties()).deep.equal([ + ['geometry', '0.5 104 0 0'], + ['id', '67'], + ['name', 'Your cool point'], + ['foo', 'quux'], + ['special-property', 'special value'], + ]); + }); - /** - * **PORTING NOTES** - * - * This test is also blocked on lack of support for repeat-based itemsets. - */ - it.fails('updates when choices are from repeat', async () => { + it('returns properties with empty string when no value and choices are from secondary instance', async () => { const scenario = await Scenario.init( - 'Select from repeat', + 'Select with empty value', html( head( - title('Select from repeat'), + title('Select with empty value'), model( - mainInstance( - t( - "data id='repeat-select'", - t('repeat', t('value'), t('label'), t('special-property')), - t('filter'), - t('select') - ) - ) + mainInstance(t("data id='select-empty'", t('select'))), + instance('choices', t('item', t('label', 'Item'), t('details', ''))) ) ), - body( - repeat('/data/repeat', input('value'), input('label'), input('special-property')), - input('filter'), - select1Dynamic('/data/select', '../repeat') - ) + body(select1Dynamic('/data/select', "instance('choices')/root/item", 'name', 'label')) ) ); - scenario.answer('/data/repeat[0]/value', 'a'); - scenario.answer('/data/repeat[0]/label', 'A'); - scenario.answer('/data/repeat[0]/special-property', 'AA'); - expect(scenario.choicesOf('/data/select').get(0)?.getValue()).toBe('a'); - expect(scenario.choicesOf('/data/select').get(0)?.getChild('special-property')).toBe('AA'); - - scenario.answer('/data/repeat[0]/special-property', 'changed'); - - expect(scenario.choicesOf('/data/select').get(0)?.getChild('special-property')).toBe( - 'changed' - ); - }); - - /** - * **PORTING NOTES** - * - * In theory, this could be made to pass! It makes more sense to fail it - * with the same error as the others above, as it is also subject to the - * same API design considerations. It also may be moot depending on our - * posture towards inline select items generally. - */ - it.fails('returns null when called on a choice from [an] inline select', async () => { - const scenario = await Scenario.init( - 'Static select', - html( - head( - title('Static select'), - model(mainInstance(t("data id='static-select'", t('select')))) - ), - body(select1('/data/select', item('one', 'One'), item('two', 'Two'))) - ) - ); - - expect(scenario.choicesOf('/data/select').get(0)?.getChild('invalid-property')).toBe( - nullValue() - ); - }); - }); - - /** - * **PORTING NOTES** - * - * It is already obvious at the outset that this API will fall into the same - * category as `getChild` above. Minimal effort has gone into porting these. - * Any further notes that might arise will come from further analysis when the - * affected functionality is prioritized. - */ - describe('`getAdditionalChildren`', () => { - it.fails('returns children in order when choices are from secondary instance', async () => { - setUpSimpleReferenceManager(r('external-select-geojson.xml').getParent(), 'file'); - - const scenario = await Scenario.init('external-select-geojson.xml'); - - const firstNodeChildren = scenario.choicesOf('/data/q').get(0)?.getAdditionalChildren(); - - expect(firstNodeChildren?.size()).toBe(3); - expect(firstNodeChildren?.get(0)).toEqual(['geometry', '0.5 102 0 0']); - expect(firstNodeChildren?.get(1)).toEqual(['id', 'fs87b']); - expect(firstNodeChildren?.get(2)).toEqual(['foo', 'bar']); - - const secondNodeChildren = scenario.choicesOf('/data/q').get(1)?.getAdditionalChildren(); - - expect(secondNodeChildren?.size()).toBe(4); - expect(secondNodeChildren?.get(0)).toEqual(['geometry', '0.5 104 0 0']); - expect(secondNodeChildren?.get(1)).toEqual(['id', '67']); - expect(secondNodeChildren?.get(2)).toEqual(['foo', 'quux']); - expect(secondNodeChildren?.get(3)).toEqual(['special-property', 'special value']); + expect(scenario.choicesOf('/data/select').get(0)?.getProperties()).deep.equal([ + ['label', 'Item'], + ['details', ''], + ]); }); /** * **PORTING NOTES** * - * The corresponding JavaRosa test name begins with `getChildren`, which seems - * to be a typo (or surprising shorthand) for `getAdditionalChildren + * This test is blocked on lack of support for repeat-based itemsets. */ - it.fails('updates when choices are from repeat', async () => { + it.fails('updates when choices are from repeat and preserve order', async () => { const scenario = await Scenario.init( 'Select from repeat', html( @@ -811,28 +707,18 @@ describe('SelectChoiceTest.java', () => { scenario.answer('/data/repeat[0]/special-property', 'AA'); expect(scenario.choicesOf('/data/select').get(0)?.getValue()).toBe('a'); - - let children = scenario.choicesOf('/data/select').get(0)?.getAdditionalChildren(); - - expect(children?.size()).toBe(2); - expect(children?.get(0)).toEqual(['value', 'a']); - expect(children?.get(1)).toEqual(['special-property', 'AA']); + expect(scenario.choicesOf('/data/select').get(0)?.getProperties()).deep.equal([ + ['special-property', 'AA'], + ]); scenario.answer('/data/repeat[0]/special-property', 'changed'); - children = scenario.choicesOf('/data/select').get(0)?.getAdditionalChildren(); - - expect(children?.get(1)).toEqual(['special-property', 'changed']); + expect(scenario.choicesOf('/data/select').get(0)?.getProperties()).deep.equal([ + ['special-property', 'changed'], + ]); }); - /** - * **PORTING NOTES** - * - * Like the inline (non-itemset) select test for `getChild`, this could be - * made to pass, but was left failing with the rest of the sub-suite based - * on the same reasoning. - */ - it.fails('returns empty when called on a choice from inline select', async () => { + it('returns empty properties when called on a choice from [an] inline select', async () => { const scenario = await Scenario.init( 'Static select', html( @@ -844,9 +730,7 @@ describe('SelectChoiceTest.java', () => { ) ); - expect(scenario.choicesOf('/data/select').get(0)?.getAdditionalChildren().isEmpty()).toBe( - true - ); + expect(scenario.choicesOf('/data/select').get(0)?.getProperties()).deep.equal([]); }); }); }); diff --git a/packages/web-forms/package.json b/packages/web-forms/package.json index 77be3c45b..4a52bd023 100644 --- a/packages/web-forms/package.json +++ b/packages/web-forms/package.json @@ -78,8 +78,9 @@ "vue": "^3.5.18" }, "dependencies": { - "vue-draggable-plus": "^0.6.0", - "@mdi/js": "^7.4.47" + "@mdi/js": "^7.4.47", + "ol": "^10.6.1", + "vue-draggable-plus": "^0.6.0" }, "publishConfig": { "access": "public" diff --git a/packages/web-forms/src/assets/images/map-location.svg b/packages/web-forms/src/assets/images/map-location.svg new file mode 100644 index 000000000..8aae37881 --- /dev/null +++ b/packages/web-forms/src/assets/images/map-location.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/web-forms/src/assets/styles/reset.scss b/packages/web-forms/src/assets/styles/reset.scss index c4bf99e2f..57571705b 100644 --- a/packages/web-forms/src/assets/styles/reset.scss +++ b/packages/web-forms/src/assets/styles/reset.scss @@ -2,6 +2,7 @@ ::before, ::after { box-sizing: inherit; + -webkit-tap-highlight-color: transparent; } html { diff --git a/packages/web-forms/src/assets/styles/style.scss b/packages/web-forms/src/assets/styles/style.scss index 313a535fc..d289de837 100644 --- a/packages/web-forms/src/assets/styles/style.scss +++ b/packages/web-forms/src/assets/styles/style.scss @@ -34,6 +34,9 @@ --odk-warning-text-color: var(--p-yellow-600); --odk-warning-background-color: var(--p-yellow-50); + --odk-success-text-color: var(--p-green-500); + --odk-success-background-color: var(--p-green-50); + --odk-text-color: var(--p-surface-900); --odk-inverted-text-color: var(--p-surface-0); --odk-muted-text-color: var(--p-surface-500); diff --git a/packages/web-forms/src/components/common/IconSVG.vue b/packages/web-forms/src/components/common/IconSVG.vue index c553f595e..50adc3500 100644 --- a/packages/web-forms/src/components/common/IconSVG.vue +++ b/packages/web-forms/src/components/common/IconSVG.vue @@ -3,19 +3,25 @@ import { computed } from 'vue'; import { mdiAlert, mdiAlertCircleOutline, + mdiArrowExpandAll, mdiCamera, mdiCheck, + mdiCheckCircle, mdiChevronDown, mdiChevronUp, mdiClose, + mdiContentSave, + mdiCrosshairsGps, mdiDotsVertical, mdiDownload, mdiDragVertical, mdiEyeOutline, mdiFileOutline, + mdiFullscreen, mdiImage, mdiMapMarkerOutline, mdiMenu, + mdiPencil, mdiPlus, mdiPrinter, mdiRefresh, @@ -28,19 +34,25 @@ import { const iconMap: Record = { mdiAlert, mdiAlertCircleOutline, + mdiArrowExpandAll, mdiCamera, mdiCheck, + mdiCheckCircle, mdiChevronDown, mdiChevronUp, mdiClose, + mdiContentSave, + mdiCrosshairsGps, mdiDotsVertical, mdiDownload, mdiDragVertical, mdiEyeOutline, mdiFileOutline, + mdiFullscreen, mdiImage, mdiMapMarkerOutline, mdiMenu, + mdiPencil, mdiPlus, mdiPrinter, mdiRefresh, @@ -50,7 +62,7 @@ const iconMap: Record = { }; type IconName = keyof typeof iconMap; -type IconVariant = 'base' | 'error' | 'inverted' | 'muted' | 'primary' | 'warning'; +type IconVariant = 'base' | 'error' | 'inverted' | 'muted' | 'primary' | 'success' | 'warning'; type IconSize = 'md' | 'sm'; /** @@ -113,6 +125,10 @@ const iconSize = computed(() => props.size ?? 'md'); fill: var(--odk-warning-text-color); } +.odk-icon.success path { + fill: var(--odk-success-text-color); +} + .odk-icon.odk-icon-sm { height: 14px; width: 14px; diff --git a/packages/web-forms/src/components/common/map/AsyncMap.vue b/packages/web-forms/src/components/common/map/AsyncMap.vue new file mode 100644 index 000000000..b49689d27 --- /dev/null +++ b/packages/web-forms/src/components/common/map/AsyncMap.vue @@ -0,0 +1,224 @@ + + + + + + + + Unable to load map + + + + + + + + + + diff --git a/packages/web-forms/src/components/common/map/MapBlock.vue b/packages/web-forms/src/components/common/map/MapBlock.vue new file mode 100644 index 000000000..1bd8cbb68 --- /dev/null +++ b/packages/web-forms/src/components/common/map/MapBlock.vue @@ -0,0 +1,282 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ mapHandler.errorMessage.value.title }} + + {{ mapHandler.errorMessage.value.message }} + + + + + diff --git a/packages/web-forms/src/components/common/map/MapProperties.vue b/packages/web-forms/src/components/common/map/MapProperties.vue new file mode 100644 index 000000000..a93b37cad --- /dev/null +++ b/packages/web-forms/src/components/common/map/MapProperties.vue @@ -0,0 +1,141 @@ + + + + + + {{ reservedProps.odk_label ?? reservedProps.odk_geometry }} + + + + + + + + {{ key }}{{ value }} + + + + + + + + diff --git a/packages/web-forms/src/components/common/map/MapStatusBar.vue b/packages/web-forms/src/components/common/map/MapStatusBar.vue new file mode 100644 index 000000000..cea8d353f --- /dev/null +++ b/packages/web-forms/src/components/common/map/MapStatusBar.vue @@ -0,0 +1,66 @@ + + + + + + + + + Point saved + + + + View details + + + + + + + No point saved + + + + + diff --git a/packages/web-forms/src/components/common/map/map-styles.ts b/packages/web-forms/src/components/common/map/map-styles.ts new file mode 100644 index 000000000..e166dede3 --- /dev/null +++ b/packages/web-forms/src/components/common/map/map-styles.ts @@ -0,0 +1,129 @@ +import type { Rule } from 'ol/style/flat'; +import mapLocationIcon from '@/assets/images/map-location.svg'; + +const DEFAULT_STROKE_COLOR = '#3E9FCC'; + +const DEFAULT_POINT_STYLE = { + 'icon-src': mapLocationIcon, + 'icon-width': 40, + 'icon-height': 40, +}; + +const DEFAULT_FEATURE_STYLE = { + 'stroke-width': 4, + 'stroke-color': DEFAULT_STROKE_COLOR, + 'fill-color': 'rgba(233, 248, 255, 0.8)', +}; + +const SCALE_POINT_STYLE = { + 'icon-src': mapLocationIcon, + 'icon-width': 50, + 'icon-height': 50, +}; + +const SCALE_FEATURE_STYLE = { + 'stroke-width': 6, +}; + +const OUTLINE_STROKE_WIDTH = 20; + +const BLUE_GLOW_COLOR = 'rgba(148, 224, 237, 0.7)'; + +const BLUE_GLOW_POINT_STYLE = { + 'circle-radius': 30, + 'circle-fill-color': BLUE_GLOW_COLOR, + 'circle-displacement': [0, 0], +}; + +const BLUE_GLOW_FEATURE_STYLE = { + 'stroke-width': OUTLINE_STROKE_WIDTH, + 'stroke-color': BLUE_GLOW_COLOR, + 'fill-color': 'transparent', +}; + +const GREEN_GLOW_COLOR = 'rgba(34, 197, 94, 0.6)'; + +const GREEN_GLOW_POINT_STYLE = { + 'circle-radius': 30, + 'circle-fill-color': GREEN_GLOW_COLOR, + 'circle-displacement': [0, 0], +}; + +const GREEN_GLOW_FEATURE_STYLE = { + 'stroke-width': OUTLINE_STROKE_WIDTH, + 'stroke-color': GREEN_GLOW_COLOR, + 'fill-color': 'transparent', +}; + +// Increases the clickable area of the Line feature. +const LINE_HIT_TOLERANCE = { + 'stroke-width': OUTLINE_STROKE_WIDTH, + 'stroke-color': 'rgba( 255, 255, 255, 0.1)', +}; + +const makeFilter = (types: string[], additionalFilters: unknown[]) => { + return ['all', ['in', ['geometry-type'], ['literal', types]], ...additionalFilters]; +}; + +export function getUnselectedStyles( + featureIdProp: string, + selectedPropName: string, + savedPropName: string +): Rule[] { + const filters = [ + ['!=', ['get', featureIdProp], ['var', selectedPropName]], + ['!=', ['get', featureIdProp], ['var', savedPropName]], + ]; + + return [ + { + filter: makeFilter(['Point'], filters), + style: DEFAULT_POINT_STYLE, + }, + { + filter: makeFilter(['LineString', 'Polygon'], filters), + style: DEFAULT_FEATURE_STYLE, + }, + { + filter: makeFilter(['LineString'], filters), + style: LINE_HIT_TOLERANCE, + }, + ]; +} + +export function getSelectedStyles( + featureIdProp: string, + selectedPropName: string, + savedPropName: string +): Rule[] { + const filters = [ + ['==', ['get', featureIdProp], ['var', selectedPropName]], + ['!=', ['get', featureIdProp], ['var', savedPropName]], + ]; + + return [ + { + filter: makeFilter(['Point'], filters), + style: [BLUE_GLOW_POINT_STYLE, DEFAULT_POINT_STYLE, SCALE_POINT_STYLE], + }, + { + filter: makeFilter(['LineString', 'Polygon'], filters), + style: [BLUE_GLOW_FEATURE_STYLE, DEFAULT_FEATURE_STYLE, SCALE_FEATURE_STYLE], + }, + ]; +} + +export function getSavedStyles(featureIdProp: string, savedPropName: string): Rule[] { + const filter = ['==', ['get', featureIdProp], ['var', savedPropName]]; + + return [ + { + filter: makeFilter(['Point'], [filter]), + style: [GREEN_GLOW_POINT_STYLE, DEFAULT_POINT_STYLE, SCALE_POINT_STYLE], + }, + { + filter: makeFilter(['LineString', 'Polygon'], [filter]), + style: [GREEN_GLOW_FEATURE_STYLE, DEFAULT_FEATURE_STYLE, SCALE_FEATURE_STYLE], + }, + ]; +} diff --git a/packages/web-forms/src/components/common/map/useMapBlock.ts b/packages/web-forms/src/components/common/map/useMapBlock.ts new file mode 100644 index 000000000..f5783e849 --- /dev/null +++ b/packages/web-forms/src/components/common/map/useMapBlock.ts @@ -0,0 +1,307 @@ +import { + getSavedStyles, + getSelectedStyles, + getUnselectedStyles, +} from '@/components/common/map/map-styles.ts'; +import type { FeatureCollection } from 'geojson'; +import { Map, MapBrowserEvent, View } from 'ol'; +import { Zoom } from 'ol/control'; +import { getCenter } from 'ol/extent'; +import Feature from 'ol/Feature'; +import GeoJSON from 'ol/format/GeoJSON'; +import { LineString, Point, Polygon } from 'ol/geom'; +import TileLayer from 'ol/layer/Tile'; +import WebGLVectorLayer from 'ol/layer/WebGLVector'; +import type { Pixel } from 'ol/pixel'; +import { fromLonLat } from 'ol/proj'; +import { OSM } from 'ol/source'; +import VectorSource from 'ol/source/Vector'; +import { computed, shallowRef, watch } from 'vue'; +import { get as getProjection } from 'ol/proj'; + +type GeometryType = LineString | Point | Polygon; + +const STATES = { + LOADING: 'loading', + READY: 'ready', + ERROR: 'error', +} as const; + +const DEFAULT_GEOJSON_PROJECTION = 'EPSG:4326'; +const DEFAULT_VIEW_PROJECTION = 'EPSG:3857'; +const DEFAULT_VIEW_CENTER = [0, 0]; +const MAX_ZOOM = 16; +const MIN_ZOOM = 2; +const GEOLOCATION_TIMEOUT_MS = 10 * 1000; +const ANIMATION_TIME = 1000; +const SMALL_DEVICE_WIDTH = 576; +const FEATURE_ID_PROPERTY = 'odk_feature_id'; +const SAVED_ID_PROPERTY = 'savedId'; +const SELECTED_ID_PROPERTY = 'selectedId'; + +export function useMapBlock() { + const currentState = shallowRef<(typeof STATES)[keyof typeof STATES]>(STATES.LOADING); + const errorMessage = shallowRef<{ title: string; message: string } | undefined>(); + let mapInstance: Map | undefined; + const savedFeature = shallowRef | undefined>(); + const selectedFeature = shallowRef | undefined>(); + const selectedFeatureProperties = computed(() => { + return selectedFeature.value?.getProperties(); + }); + + const featuresSource = new VectorSource(); + const featuresVectorLayer = new WebGLVectorLayer({ + source: featuresSource, + style: [ + ...getUnselectedStyles(FEATURE_ID_PROPERTY, SELECTED_ID_PROPERTY, SAVED_ID_PROPERTY), + ...getSelectedStyles(FEATURE_ID_PROPERTY, SELECTED_ID_PROPERTY, SAVED_ID_PROPERTY), + ...getSavedStyles(FEATURE_ID_PROPERTY, SAVED_ID_PROPERTY), + ], + variables: { [SAVED_ID_PROPERTY]: '', [SELECTED_ID_PROPERTY]: '' }, + }); + + const initializeMap = (mapContainer: HTMLElement, geoJSON: FeatureCollection): void => { + if (mapInstance) { + return; + } + + mapInstance = new Map({ + target: mapContainer, + layers: [new TileLayer({ source: new OSM() }), featuresVectorLayer], + view: new View({ + center: DEFAULT_VIEW_CENTER, + zoom: MIN_ZOOM, + // Prevent map cloning at low zoom during panning, which disrupts feature selection. + multiWorld: false, + projection: DEFAULT_VIEW_PROJECTION, + extent: getProjection(DEFAULT_VIEW_PROJECTION)?.getExtent(), + }), + controls: [new Zoom()], + }); + + currentState.value = STATES.READY; + loadGeometries(geoJSON); + }; + + const setCursorPointer = (event: MapBrowserEvent) => { + if (event.dragging || !mapInstance) { + return; + } + + const hit = mapInstance.hasFeatureAtPixel(event.pixel, { + layerFilter: (layer) => layer instanceof WebGLVectorLayer, + }); + + mapInstance.getTargetElement().style.cursor = hit ? 'pointer' : ''; + }; + + const handleClick = (event: MapBrowserEvent) => selectFeatureByPosition(event.pixel); + + const toggleClickBinding = (bindClick: boolean) => { + mapInstance?.un('click', handleClick); + mapInstance?.un('pointermove', setCursorPointer); + + if (bindClick) { + mapInstance?.on('click', handleClick); + mapInstance?.on('pointermove', setCursorPointer); + } + }; + + const fitToAllFeatures = (): void => { + const extent = featuresSource.getExtent(); + if (extent) { + mapInstance?.getView().fit(extent, { + padding: [50, 50, 50, 50], + duration: ANIMATION_TIME, + maxZoom: MAX_ZOOM, + }); + } + }; + + const centerCurrentLocation = (): void => { + // TODO: translations + const friendlyError = { + title: 'Cannot access location', + message: + 'Grant location permission in the browser settings and make sure location is turned on.', + }; + + if (!navigator.geolocation) { + currentState.value = STATES.ERROR; + errorMessage.value = friendlyError; + } + + navigator.geolocation.getCurrentPosition( + (position) => { + const coords = fromLonLat([position.coords.longitude, position.coords.latitude]); + mapInstance + ?.getView() + .animate({ center: coords, zoom: MAX_ZOOM, duration: ANIMATION_TIME }); + currentState.value = STATES.READY; + }, + () => { + currentState.value = STATES.ERROR; + errorMessage.value = friendlyError; + }, + { enableHighAccuracy: true, timeout: GEOLOCATION_TIMEOUT_MS } + ); + }; + + const centerFeatureLocation = (feature: Feature): void => { + const geometry = feature.getGeometry(); + const view = mapInstance?.getView(); + const mapWidth = mapInstance?.getSize()?.[0]; + if (!geometry || !view || mapWidth == null) { + return; + } + + const pixelOffsetY = mapWidth < SMALL_DEVICE_WIDTH ? -130 : 0; + const pixelOffsetX = mapWidth < SMALL_DEVICE_WIDTH ? 0 : -70; + + const zoomResolution = view.getResolution() ?? 1; + const xOffsetInMapUnits = -pixelOffsetX * zoomResolution; + const yOffsetInMapUnits = -pixelOffsetY * zoomResolution; + + // Turning angles into usable numbers + const rotation = view.getRotation(); + const cosRotation = Math.cos(rotation); + const sinRotation = Math.sin(rotation); + + const [featureCenterLong, featureCenterLat] = getCenter(geometry.getExtent()); + const targetCoordinates = [ + featureCenterLong - xOffsetInMapUnits * cosRotation + yOffsetInMapUnits * sinRotation, + featureCenterLat - xOffsetInMapUnits * sinRotation - yOffsetInMapUnits * cosRotation, + ]; + + view.animate({ + center: targetCoordinates, + duration: ANIMATION_TIME, + }); + }; + + const loadGeometries = (geoJSON: FeatureCollection): void => { + if (!mapInstance) { + return; + } + + currentState.value = STATES.LOADING; + selectFeature(undefined); + saveFeature(undefined); + featuresSource.clear(true); + + if (!geoJSON.features.length) { + mapInstance?.getView().animate({ + center: DEFAULT_VIEW_CENTER, + zoom: MIN_ZOOM, + duration: ANIMATION_TIME, + }); + currentState.value = STATES.READY; + return; + } + + const features = new GeoJSON().readFeatures(geoJSON, { + dataProjection: DEFAULT_GEOJSON_PROJECTION, + featureProjection: mapInstance.getView().getProjection(), + }); + + features.forEach((feature) => { + if (!feature.get(FEATURE_ID_PROPERTY)) { + feature.set(FEATURE_ID_PROPERTY, crypto.randomUUID()); + } + }); + featuresSource.addFeatures(features); + currentState.value = STATES.READY; + + fitToAllFeatures(); + }; + + const selectFeatureByPosition = (position: Pixel): void => { + const hitFeatures = mapInstance?.getFeaturesAtPixel(position, { + layerFilter: (layer) => layer instanceof WebGLVectorLayer, + }); + + const featureToSelect = hitFeatures?.length + ? (hitFeatures[0] as Feature) + : undefined; + + selectFeature(featureToSelect); + }; + + const selectFeature = (feature?: Feature) => (selectedFeature.value = feature); + + const saveFeature = (feature?: Feature) => (savedFeature.value = feature); + + const setSavedByValueProp = (value: string | undefined): void => { + if (!value?.length) { + return; + } + + const featureToSave = featuresSource.forEachFeature((feature) => { + return feature.getProperties()?.odk_value === value ? feature : undefined; + }); + + if (!featureToSave) { + return; + } + + saveFeature(featureToSave as Feature); + centerFeatureLocation(featureToSave as Feature); + }; + + const isSelectedFeatureSaved = (): boolean => { + const savedId = savedFeature.value?.get(FEATURE_ID_PROPERTY) as string; + const selectedId = selectedFeature.value?.get(FEATURE_ID_PROPERTY) as string; + return savedId?.length > 0 && savedId === selectedId; + }; + + watch( + () => currentState.value, + (newState) => { + if (newState !== STATES.ERROR) { + errorMessage.value = undefined; + } + } + ); + + watch( + () => selectedFeature.value, + (newSelectedFeature) => { + featuresVectorLayer.updateStyleVariables({ + [SELECTED_ID_PROPERTY]: (newSelectedFeature?.get(FEATURE_ID_PROPERTY) as string) ?? '', + }); + + if (newSelectedFeature != null) { + centerFeatureLocation(newSelectedFeature); + } + } + ); + + watch( + () => savedFeature.value, + (newSavedFeature) => { + featuresVectorLayer.updateStyleVariables({ + [SAVED_ID_PROPERTY]: (newSavedFeature?.get(FEATURE_ID_PROPERTY) as string) ?? '', + }); + } + ); + + return { + initializeMap, + loadGeometries, + errorMessage, + toggleClickBinding, + + centerCurrentLocation, + fitToAllFeatures, + + savedFeature, + discardSavedFeature: () => saveFeature(undefined), + saveFeature: () => saveFeature(selectedFeature.value), + setSavedByValueProp, + + selectedFeatureProperties, + selectSavedFeature: () => selectFeature(savedFeature.value), + unselectFeature: () => selectFeature(undefined), + isSelectedFeatureSaved, + }; +} diff --git a/packages/web-forms/src/components/form-elements/select/Select1Control.vue b/packages/web-forms/src/components/form-elements/select/Select1Control.vue index 9ee74d14d..4ab0e359b 100644 --- a/packages/web-forms/src/components/form-elements/select/Select1Control.vue +++ b/packages/web-forms/src/components/form-elements/select/Select1Control.vue @@ -2,6 +2,7 @@ import ColumnarAppearance from '@/components/appearances/ColumnarAppearance.vue'; import FieldListTable from '@/components/appearances/FieldListTable.vue'; import UnsupportedAppearance from '@/components/appearances/UnsupportedAppearance.vue'; +import AsyncMap from '@/components/common/map/AsyncMap.vue'; import ControlText from '@/components/form-elements/ControlText.vue'; import ValidationMessage from '@/components/common/ValidationMessage.vue'; import LikertWidget from '@/components/common/LikertWidget.vue'; @@ -18,6 +19,12 @@ const props = defineProps(); const isSelectWithImages = computed(() => props.question.currentState.isSelectWithImages); const hasColumnsAppearance = ref(false); const hasFieldListRelatedAppearance = ref(false); +const savedFeatureValue = computed(() => { + if (!props.question.appearances.map) { + return; + } + return props.question.currentState.value?.[0]; +}); watchEffect(() => { const appearances = [...props.question.appearances]; @@ -47,7 +54,19 @@ watchEffect(() => { :question="question" /> - + question.selectValue(value ?? '')" + /> + + @@ -56,12 +75,16 @@ watchEffect(() => { - + - + { touched.value = true; stopWatch(); - }, - { deep: true } + } ); const questionHasError = computed(() => { diff --git a/packages/xforms-engine/src/client/BaseItem.ts b/packages/xforms-engine/src/client/BaseItem.ts new file mode 100644 index 000000000..947bb8aa5 --- /dev/null +++ b/packages/xforms-engine/src/client/BaseItem.ts @@ -0,0 +1,7 @@ +import type { TextRange } from './TextRange.ts'; + +export interface BaseItem { + get label(): TextRange<'item-label'>; + get value(): string; + properties: Array<[string, string]>; +} diff --git a/packages/xforms-engine/src/client/RankNode.ts b/packages/xforms-engine/src/client/RankNode.ts index 94510fa9c..225fb3cc2 100644 --- a/packages/xforms-engine/src/client/RankNode.ts +++ b/packages/xforms-engine/src/client/RankNode.ts @@ -1,6 +1,7 @@ import type { RankControlDefinition } from '../parse/body/control/RankControlDefinition.ts'; import type { LeafNodeDefinition } from '../parse/model/LeafNodeDefinition.ts'; import type { BaseValueNode, BaseValueNodeState } from './BaseValueNode.ts'; +import type { BaseItem } from './BaseItem.ts'; import type { RootNode } from './RootNode.ts'; import type { TextRange } from './TextRange.ts'; import type { GeneralParentNode } from './hierarchy.ts'; @@ -8,11 +9,7 @@ import type { LeafNodeValidationState } from './validation.ts'; import type { UnknownAppearanceDefinition } from '../parse/body/appearance/unknownAppearanceParser.ts'; import type { ValueType } from './ValueType.ts'; -export interface RankItem { - get label(): TextRange<'item-label'>; - get value(): string; -} - +export type RankItem = BaseItem; export type RankValueOptions = readonly RankItem[]; export interface RankNodeState extends BaseValueNodeState { diff --git a/packages/xforms-engine/src/client/SelectNode.ts b/packages/xforms-engine/src/client/SelectNode.ts index f9844a734..f811e453e 100644 --- a/packages/xforms-engine/src/client/SelectNode.ts +++ b/packages/xforms-engine/src/client/SelectNode.ts @@ -4,18 +4,14 @@ import type { } from '../parse/body/control/SelectControlDefinition.ts'; import type { LeafNodeDefinition } from '../parse/model/LeafNodeDefinition.ts'; import type { BaseValueNode, BaseValueNodeState } from './BaseValueNode.ts'; +import type { BaseItem } from './BaseItem.ts'; import type { NodeAppearances } from './NodeAppearances.ts'; import type { RootNode } from './RootNode.ts'; -import type { TextRange } from './TextRange.ts'; import type { ValueType } from './ValueType.ts'; import type { GeneralParentNode } from './hierarchy.ts'; import type { LeafNodeValidationState } from './validation.ts'; -export interface SelectItem { - get label(): TextRange<'item-label'>; - get value(): string; -} - +export type SelectItem = BaseItem; export type SelectValueOptions = readonly SelectItem[]; export interface SelectNodeState extends BaseValueNodeState { diff --git a/packages/xforms-engine/src/lib/reactivity/createItemCollection.ts b/packages/xforms-engine/src/lib/reactivity/createItemCollection.ts index f7a7f75e6..10ebc31af 100644 --- a/packages/xforms-engine/src/lib/reactivity/createItemCollection.ts +++ b/packages/xforms-engine/src/lib/reactivity/createItemCollection.ts @@ -2,13 +2,12 @@ import { UpsertableMap } from '@getodk/common/lib/collections/UpsertableMap.ts'; import type { Accessor } from 'solid-js'; import { createMemo } from 'solid-js'; import type { ActiveLanguage } from '../../client/FormLanguage.ts'; -import type { SelectItem } from '../../client/SelectNode.ts'; -import type { RankItem } from '../../client/RankNode.ts'; +import type { BaseItem } from '../../client/BaseItem.ts'; import type { TextRange as ClientTextRange } from '../../client/TextRange.ts'; import type { EvaluationContext } from '../../instance/internal-api/EvaluationContext.ts'; import type { TranslationContext } from '../../instance/internal-api/TranslationContext.ts'; -import type { SelectControl } from '../../instance/SelectControl.ts'; import type { RankControl } from '../../instance/RankControl.ts'; +import type { SelectControl } from '../../instance/SelectControl.ts'; import { TextChunk } from '../../instance/text/TextChunk.ts'; import { TextRange } from '../../instance/text/TextRange.ts'; import type { EngineXPathNode } from '../../integration/xpath/adapter/kind.ts'; @@ -19,8 +18,7 @@ import { createComputedExpression } from './createComputedExpression.ts'; import type { ReactiveScope } from './scope.ts'; import { createTextRange } from './text/createTextRange.ts'; -export type ItemCollectionControl = RankControl | SelectControl; -type Item = RankItem | SelectItem; +type ItemCollectionControl = RankControl | SelectControl; type DerivedItemLabel = ClientTextRange<'item-label', 'form-derived'>; const derivedItemLabel = (context: TranslationContext, value: string): DerivedItemLabel => { @@ -45,7 +43,7 @@ const createItemLabel = ( const createTranslatedStaticItems = ( control: ItemCollectionControl, items: readonly ItemDefinition[] -): Accessor => { +): Accessor => { return control.scope.runTask(() => { const labeledItems = items.map((item) => { const { value } = item; @@ -54,6 +52,7 @@ const createTranslatedStaticItems = ( return () => ({ value, label: label(), + properties: [], }); }); @@ -101,6 +100,7 @@ const createItemsetItemLabel = ( interface ItemsetItem { label(): ClientTextRange<'item-label'>; value(): string; + properties: Array<[string, () => string]>; } const createItemsetItems = ( @@ -122,9 +122,20 @@ const createItemsetItems = ( }); const label = createItemsetItemLabel(context, itemset, value); + const nodeElements = itemNode + .getXPathChildNodes() + .filter((node) => node.nodeType === 'static-element'); + const properties = itemset.getPropertiesExpressions(nodeElements).map((expression) => { + return [expression.toString(), createComputedExpression(context, expression)] as [ + string, + () => string, + ]; + }); + return { label, value, + properties, }; }); }); @@ -135,7 +146,7 @@ const createItemsetItems = ( const createItemset = ( control: ItemCollectionControl, itemset: ItemsetDefinition -): Accessor => { +): Accessor => { return control.scope.runTask(() => { const itemsetItems = createItemsetItems(control, itemset); @@ -144,6 +155,9 @@ const createItemset = ( return { label: item.label(), value: item.value(), + properties: item.properties.map( + ([propLabel, propValue]) => [propLabel, propValue()] as [string, string] + ), }; }); }); @@ -152,7 +166,7 @@ const createItemset = ( /** * Creates a reactive computation of a {@link ItemCollectionControl}'s - * {@link Item}s, in support of the field's `valueOptions`. + * {@link BaseItem}s, in support of the field's `valueOptions`. * * - The control defined with static ``s will compute to an corresponding * static list of items. @@ -162,7 +176,9 @@ const createItemset = ( * their appropriate dependencies (whether relative to the itemset item node, * referencing a form's `itext` translations, etc). */ -export const createItemCollection = (control: ItemCollectionControl): Accessor => { +export const createItemCollection = ( + control: ItemCollectionControl +): Accessor => { const { items, itemset } = control.definition.bodyElement; if (itemset != null) { diff --git a/packages/xforms-engine/src/parse/body/control/ItemsetDefinition.ts b/packages/xforms-engine/src/parse/body/control/ItemsetDefinition.ts index 593bd7700..2c7960c13 100644 --- a/packages/xforms-engine/src/parse/body/control/ItemsetDefinition.ts +++ b/packages/xforms-engine/src/parse/body/control/ItemsetDefinition.ts @@ -1,5 +1,8 @@ +import type { StaticElement } from '../../../integration/xpath/static-dom/StaticElement.ts'; import type { ItemsetElement } from '../../../lib/dom/query.ts'; import { getValueElement } from '../../../lib/dom/query.ts'; +import { DependentExpression } from '../../expression/abstract/DependentExpression.ts'; +import { ItemPropertyExpression } from '../../expression/ItemPropertyExpression.ts'; import { ItemsetNodesetExpression } from '../../expression/ItemsetNodesetExpression.ts'; import { ItemsetValueExpression } from '../../expression/ItemsetValueExpression.ts'; import { ItemsetLabelDefinition } from '../../text/ItemsetLabelDefinition.ts'; @@ -52,4 +55,8 @@ export class ItemsetDefinition extends BodyElementDefinition<'itemset'> { this.value = new ItemsetValueExpression(this, valueExpression); this.label = ItemsetLabelDefinition.from(form, this); } + + getPropertiesExpressions(propertiesNodes: StaticElement[]): Array> { + return ItemPropertyExpression.from(propertiesNodes); + } } diff --git a/packages/xforms-engine/src/parse/expression/ItemPropertyExpression.ts b/packages/xforms-engine/src/parse/expression/ItemPropertyExpression.ts new file mode 100644 index 000000000..59c400775 --- /dev/null +++ b/packages/xforms-engine/src/parse/expression/ItemPropertyExpression.ts @@ -0,0 +1,12 @@ +import type { StaticElement } from '../../integration/xpath/static-dom/StaticElement.ts'; +import { DependentExpression } from './abstract/DependentExpression.ts'; + +export class ItemPropertyExpression extends DependentExpression<'string'> { + static from(propertiesNodes: StaticElement[]) { + return propertiesNodes.map((node: StaticElement) => new this(node.qualifiedName.localName)); + } + + constructor(propertyName: string) { + super('string', propertyName); + } +} diff --git a/packages/xforms-engine/test/lib/reactivity/createItemCollection.test.ts b/packages/xforms-engine/test/lib/reactivity/createItemCollection.test.ts index 11fa546dc..625af8e8e 100644 --- a/packages/xforms-engine/test/lib/reactivity/createItemCollection.test.ts +++ b/packages/xforms-engine/test/lib/reactivity/createItemCollection.test.ts @@ -14,10 +14,7 @@ import { } from '@getodk/common/test/fixtures/xform-dsl/index.ts'; import { describe, expect, it } from 'vitest'; import { createInstance } from '../../../src/entrypoints/createInstance.ts'; -import type { - createItemCollection, - ItemCollectionControl, -} from '../../../src/lib/reactivity/createItemCollection.ts'; +import type { createItemCollection } from '../../../src/lib/reactivity/createItemCollection.ts'; import { reactiveTestScope } from '../../helpers/reactive/internal.ts'; /** diff --git a/yarn.lock b/yarn.lock index 788fce435..374ac60dd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1126,6 +1126,11 @@ "@parcel/watcher-win32-ia32" "2.5.0" "@parcel/watcher-win32-x64" "2.5.0" +"@petamoriken/float16@^3.4.7": + version "3.9.2" + resolved "https://registry.yarnpkg.com/@petamoriken/float16/-/float16-3.9.2.tgz#217a5d349f3655b8e286be447e0ed1eae063a78f" + integrity sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog== + "@pkgjs/parseargs@^0.11.0": version "0.11.0" resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" @@ -1539,6 +1544,11 @@ dependencies: types-ramda "^0.30.1" +"@types/rbush@4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@types/rbush/-/rbush-4.0.0.tgz#b327bf54952e9c924ea6702c36904c2ce1d47f35" + integrity sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ== + "@types/sortablejs@^1.15.8": version "1.15.8" resolved "https://registry.yarnpkg.com/@types/sortablejs/-/sortablejs-1.15.8.tgz#11ed555076046e00869a5ef85d1e7651e7a66ef6" @@ -2693,6 +2703,11 @@ dunder-proto@^1.0.1: es-errors "^1.3.0" gopd "^1.2.0" +earcut@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/earcut/-/earcut-3.0.2.tgz#d478a29aaf99acf418151493048aa197d0512248" + integrity sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ== + eastasianwidth@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" @@ -3198,6 +3213,20 @@ gensync@^1.0.0-beta.2: resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== +geotiff@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/geotiff/-/geotiff-2.1.3.tgz#993f40f2aa6aa65fb1e0451d86dd22ca8e66910c" + integrity sha512-PT6uoF5a1+kbC3tHmZSUsLHBp2QJlHasxxxxPW47QIY1VBKpFB+FcDvX+MxER6UzgLQZ0xDzJ9s48B9JbOCTqA== + dependencies: + "@petamoriken/float16" "^3.4.7" + lerc "^3.0.0" + pako "^2.0.4" + parse-headers "^2.0.2" + quick-lru "^6.1.1" + web-worker "^1.2.0" + xml-utils "^1.0.2" + zstddec "^0.1.0" + get-intrinsic@^1.2.5, get-intrinsic@^1.2.6: version "1.2.7" resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.7.tgz#dcfcb33d3272e15f445d15124bc0a216189b9044" @@ -3653,6 +3682,11 @@ kolorist@^1.8.0: resolved "https://registry.yarnpkg.com/kolorist/-/kolorist-1.8.0.tgz#edddbbbc7894bc13302cdf740af6374d4a04743c" integrity sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ== +lerc@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/lerc/-/lerc-3.0.0.tgz#36f36fbd4ba46f0abf4833799fff2e7d6865f5cb" + integrity sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww== + levn@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" @@ -3994,6 +4028,17 @@ object-inspect@^1.13.3: resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.3.tgz#f14c183de51130243d6d18ae149375ff50ea488a" integrity sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA== +ol@^10.6.1: + version "10.6.1" + resolved "https://registry.yarnpkg.com/ol/-/ol-10.6.1.tgz#950f3914b4eec978f087b36aa74ce1e18c41ab09" + integrity sha512-xp174YOwPeLj7c7/8TCIEHQ4d41tgTDDhdv6SqNdySsql5/MaFJEJkjlsYcvOPt7xA6vrum/QG4UdJ0iCGT1cg== + dependencies: + "@types/rbush" "4.0.0" + earcut "^3.0.0" + geotiff "^2.1.3" + pbf "4.0.1" + rbush "^4.0.0" + only-allow@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/only-allow/-/only-allow-1.2.1.tgz#8f18abd72bf531bc0e59fcfcfca590c425a5ad29" @@ -4098,6 +4143,11 @@ package-manager-detector@^0.2.0: resolved "https://registry.yarnpkg.com/package-manager-detector/-/package-manager-detector-0.2.8.tgz#f5ace2dbd37666af54e5acec11bc37c8450f72d0" integrity sha512-ts9KSdroZisdvKMWVAVCXiKqnqNfXz4+IbrBG8/BWx/TR5le+jfenvoBuIZ6UWM9nz47W7AbD9qYfAwfWMIwzA== +pako@^2.0.4: + version "2.1.0" + resolved "https://registry.yarnpkg.com/pako/-/pako-2.1.0.tgz#266cc37f98c7d883545d11335c00fbd4062c9a86" + integrity sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug== + papaparse@^5.5.3: version "5.5.3" resolved "https://registry.yarnpkg.com/papaparse/-/papaparse-5.5.3.tgz#07f8994dec516c6dab266e952bed68e1de59fa9a" @@ -4110,6 +4160,11 @@ parent-module@^1.0.0: dependencies: callsites "^3.0.0" +parse-headers@^2.0.2: + version "2.0.6" + resolved "https://registry.yarnpkg.com/parse-headers/-/parse-headers-2.0.6.tgz#7940f0abe5fe65df2dd25d4ce8800cb35b49d01c" + integrity sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A== + parse-imports-exports@^0.2.4: version "0.2.4" resolved "https://registry.yarnpkg.com/parse-imports-exports/-/parse-imports-exports-0.2.4.tgz#e3fb3b5e264cfb55c25b5dfcbe7f410f8dc4e7af" @@ -4185,6 +4240,13 @@ pathval@^2.0.0: resolved "https://registry.yarnpkg.com/pathval/-/pathval-2.0.0.tgz#7e2550b422601d4f6b8e26f1301bc8f15a741a25" integrity sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA== +pbf@4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/pbf/-/pbf-4.0.1.tgz#ad9015e022b235dcdbe05fc468a9acadf483f0d4" + integrity sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA== + dependencies: + resolve-protobuf-schema "^2.1.0" + picocolors@^1.0.0, picocolors@^1.1.0, picocolors@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" @@ -4339,6 +4401,11 @@ proto-list@~1.2.1: resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" integrity sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA== +protocol-buffers-schema@^3.3.1: + version "3.6.0" + resolved "https://registry.yarnpkg.com/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz#77bc75a48b2ff142c1ad5b5b90c94cd0fa2efd03" + integrity sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw== + pseudomap@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" @@ -4371,11 +4438,28 @@ queue-microtask@^1.2.2: resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== +quick-lru@^6.1.1: + version "6.1.2" + resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-6.1.2.tgz#e9a90524108629be35287d0b864e7ad6ceb3659e" + integrity sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ== + +quickselect@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/quickselect/-/quickselect-3.0.0.tgz#a37fc953867d56f095a20ac71c6d27063d2de603" + integrity sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g== + ramda@^0.31.3: version "0.31.3" resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.31.3.tgz#0f54199ec99a7bd6702277d28d6bf7f93b916bb9" integrity sha512-xKADKRNnqmDdX59PPKLm3gGmk1ZgNnj3k7DryqWwkamp4TJ6B36DdpyKEQ0EoEYmH2R62bV4Q+S0ym2z8N2f3Q== +rbush@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/rbush/-/rbush-4.0.1.tgz#1f55afa64a978f71bf9e9a99bc14ff84f3cb0d6d" + integrity sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ== + dependencies: + quickselect "^3.0.0" + react-is@^17.0.1: version "17.0.2" resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" @@ -4436,6 +4520,13 @@ resolve-from@^5.0.0: resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== +resolve-protobuf-schema@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz#9ca9a9e69cf192bbdaf1006ec1973948aa4a3758" + integrity sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ== + dependencies: + protocol-buffers-schema "^3.3.1" + resolve@~1.22.1, resolve@~1.22.2: version "1.22.10" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.10.tgz#b663e83ffb09bbf2386944736baae803029b8b39" @@ -5313,6 +5404,11 @@ web-tree-sitter@0.24.5: resolved "https://registry.yarnpkg.com/web-tree-sitter/-/web-tree-sitter-0.24.5.tgz#16cea449da63012f23ca7b83bd32817dd0520400" integrity sha512-+J/2VSHN8J47gQUAvF8KDadrfz6uFYVjxoxbKWDoXVsH2u7yLdarCnIURnrMA6uSRkgX3SdmqM5BOoQjPdSh5w== +web-worker@^1.2.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/web-worker/-/web-worker-1.5.0.tgz#71b2b0fbcc4293e8f0aa4f6b8a3ffebff733dcc5" + integrity sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw== + webidl-conversions@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" @@ -5436,6 +5532,11 @@ xml-name-validator@^5.0.0: resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz#82be9b957f7afdacf961e5980f1bf227c0bf7673" integrity sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg== +xml-utils@^1.0.2: + version "1.10.2" + resolved "https://registry.yarnpkg.com/xml-utils/-/xml-utils-1.10.2.tgz#436b39ccc25a663ce367ea21abb717afdea5d6b1" + integrity sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA== + xmlchars@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" @@ -5465,3 +5566,8 @@ yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +zstddec@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/zstddec/-/zstddec-0.1.0.tgz#7050f3f0e0c3978562d0c566b3e5a427d2bad7ec" + integrity sha512-w2NTI8+3l3eeltKAdK8QpiLo/flRAr2p8AGeakfMZOXBxOg9HIu4LVDxBi81sYgVhFhdJjv1OrB5ssI8uFPoLg==
+ Unable to load map +