Skip to content

Commit 99295eb

Browse files
authored
feat(#249): Adds default geopoint question type (#300)
- Introduces a demo form for geopoint question type: geopoint.xml. - Includes the new geopoint notes in the demo form 2-all-possible-notes.xml. - Implements new scenario tests for geopoint input in bind-types.test.ts. - Adds geopoint input and geopoint formatted value components to the Web Forms client. - Registers geopoint support in both the input node and note node. - Creates a geopoint codec for encoding/decoding geopoint data. - Introduces a "node options" concept for node attributes, integrated into the input control.
1 parent 86d40a9 commit 99295eb

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

53 files changed

+1615
-24
lines changed

.changeset/violet-chefs-dress.md

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@getodk/xforms-engine': minor
3+
'@getodk/web-forms': minor
4+
'@getodk/scenario': minor
5+
'@getodk/common': patch
6+
---
7+
8+
- Support for geopoint questions with no appearance
9+
- Support for geopoint notes
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<?xml version="1.0"?>
2+
<h:html xmlns="http://www.w3.org/2002/xforms" xmlns:h="http://www.w3.org/1999/xhtml"
3+
xmlns:ev="http://www.w3.org/2001/xml-events" xmlns:xsd="http://www.w3.org/2001/XMLSchema"
4+
xmlns:jr="http://openrosa.org/javarosa" xmlns:orx="http://openrosa.org/xforms"
5+
xmlns:odk="http://www.opendatakit.org/xforms">
6+
<h:head>
7+
<h:title>Geopoint</h:title>
8+
<model odk:xforms-version="1.0.0">
9+
<itext>
10+
<translation lang="English (en)">
11+
<text id="/data/intro:label">
12+
<value>The browser will display a permission prompt to allow or block location access. Click 'Allow' to enable location services. If dismissed, the prompt may not appear again unless permissions are reset in browser settings.</value>
13+
</text>
14+
<text id="/data/location_no_autosave:label">
15+
<value>Where are you filling out the survey?</value>
16+
</text>
17+
<text id="/data/location_with_big_threshold:label">
18+
<value>Where are you within the target area?</value>
19+
</text>
20+
<text id="/data/location_with_default_attributes:label">
21+
<value>Where are you within the default threshold area?</value>
22+
</text>
23+
<text id="/data/location_with_unacceptable_accuracy_only:label">
24+
<value>Try to provide a more specific location</value>
25+
</text>
26+
</translation>
27+
<translation lang="French (fr)">
28+
<text id="/data/intro:label">
29+
<value>Le navigateur affichera une demande d'autorisation pour permettre ou bloquer l'accès à la localisation. Cliquez sur 'Autoriser' pour activer les services de localisation. Si vous ignorez la demande, elle pourrait ne plus réapparaître, sauf si vous réinitialisez les autorisations dans les paramètres du navigateur.</value>
30+
</text>
31+
<text id="/data/location_no_autosave:label">
32+
<value>Où remplissez-vous le sondage?</value>
33+
</text>
34+
<text id="/data/location_with_big_threshold:label">
35+
<value>Où êtes-vous dans la zone cible?</value>
36+
</text>
37+
<text id="/data/location_with_default_attributes:label">
38+
<value>Où êtes-vous dans la zone de seuil par défaut?</value>
39+
</text>
40+
<text id="/data/location_with_unacceptable_accuracy_only:label">
41+
<value>Essayez de fournir un emplacement plus précis</value>
42+
</text>
43+
</translation>
44+
</itext>
45+
<instance>
46+
<data id="1_geopoint" version="2025020401">
47+
<intro/>
48+
<location_no_autosave/>
49+
<location_with_big_threshold/>
50+
<location_with_default_attributes/>
51+
<location_with_unacceptable_accuracy_only/>
52+
<meta>
53+
<instanceID/>
54+
</meta>
55+
</data>
56+
</instance>
57+
<bind nodeset="/data/intro" readonly="true()" type="string"/>
58+
<bind nodeset="/data/location_no_autosave" type="geopoint"/>
59+
<bind nodeset="/data/location_with_big_threshold" type="geopoint"/>
60+
<bind nodeset="/data/location_with_default_attributes" type="geopoint" required="true()"/>
61+
<bind nodeset="/data/location_with_unacceptable_accuracy_only" type="geopoint"/>
62+
<bind nodeset="/data/meta/instanceID" type="string" readonly="true()" jr:preload="uid"/>
63+
</model>
64+
</h:head>
65+
<h:body>
66+
<input ref="/data/intro">
67+
<label ref="jr:itext('/data/intro:label')"/>
68+
</input>
69+
<input accuracyThreshold="0" ref="/data/location_no_autosave">
70+
<label ref="jr:itext('/data/location_no_autosave:label')"/>
71+
<hint>(No autosave)</hint>
72+
</input>
73+
<input accuracyThreshold="100" unacceptableAccuracyThreshold="400" ref="/data/location_with_big_threshold">
74+
<label ref="jr:itext('/data/location_with_big_threshold:label')"/>
75+
<hint>(Acceptable accuracy: 100m, unacceptable: 500m)</hint>
76+
</input>
77+
<input ref="/data/location_with_default_attributes">
78+
<label ref="jr:itext('/data/location_with_default_attributes:label')"/>
79+
<hint>(Default acceptable accuracy: 5m, unacceptable: 100m)</hint>
80+
</input>
81+
<input unacceptableAccuracyThreshold="7" ref="/data/location_with_unacceptable_accuracy_only">
82+
<label ref="jr:itext('/data/location_with_unacceptable_accuracy_only:label')"/>
83+
<hint>(Unacceptable accuracy: 7m)</hint>
84+
</input>
85+
</h:body>
86+
</h:html>

packages/common/src/fixtures/notes/2-all-possible-notes.xml

+6-1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
<read_only_int />
2020
<read_only_int_value>3</read_only_int_value>
2121
<note_calc_decimal_from_int />
22+
<geopoint-note>38.253094215699576 21.756382658677467 0 150</geopoint-note>
2223
</group>
2324
<meta>
2425
<instanceID />
@@ -38,6 +39,7 @@
3839
<bind nodeset="/data/group/note_calc_decimal_from_int" type="decimal"
3940
calculate="/data/group/read_only_int_value + 1.5" readonly="true()" />
4041
<bind nodeset="/data/meta/instanceID" type="string" readonly="true()" jr:preload="uid" />
42+
<bind nodeset="/data/group/geopoint-note" type="geopoint" readonly="true()" />
4143
</model>
4244
</h:head>
4345
<h:body>
@@ -76,6 +78,9 @@
7678
<input ref="/data/group/note_calc_decimal_from_int">
7779
<label>A note with decimal type calculated from int</label>
7880
</input>
81+
<input ref="/data/group/geopoint-note">
82+
<label>A note with geopoint type</label>
83+
</input>
7984
</group>
8085
</h:body>
81-
</h:html>
86+
</h:html>

packages/common/types/timers.ts

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export type TimerID = ReturnType<typeof setTimeout>;
2+
export type IntervalID = ReturnType<typeof setInterval>;

packages/scenario/src/jr/event/InputQuestionEvent.ts

+22-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import type { AnyInputNode, DecimalInputNode, IntInputNode } from '@getodk/xforms-engine';
1+
import type {
2+
AnyInputNode,
3+
DecimalInputNode,
4+
GeopointInputNode,
5+
GeopointInputValue,
6+
IntInputNode,
7+
} from '@getodk/xforms-engine';
28
import { InputNodeAnswer } from '../../answer/InputNodeAnswer.ts';
39
import { UntypedAnswer } from '../../answer/UntypedAnswer.ts';
410
import type { ValueNodeAnswer } from '../../answer/ValueNodeAnswer.ts';
@@ -40,6 +46,18 @@ export class InputQuestionEvent extends QuestionEvent<'input'> {
4046
}
4147
}
4248

49+
private answerGeopointQuestionNode(
50+
node: GeopointInputNode,
51+
answerValue: unknown
52+
): ValueNodeAnswer {
53+
if (answerValue === null || typeof answerValue === 'object') {
54+
node.setValue(answerValue as GeopointInputValue);
55+
return new InputNodeAnswer(node);
56+
}
57+
58+
return this.answerDefault(node, answerValue);
59+
}
60+
4361
answerQuestion(answerValue: unknown): ValueNodeAnswer {
4462
const { node } = this;
4563

@@ -48,6 +66,9 @@ export class InputQuestionEvent extends QuestionEvent<'input'> {
4866
case 'decimal':
4967
return this.answerNumericQuestionNode(node, answerValue);
5068

69+
case 'geopoint':
70+
return this.answerGeopointQuestionNode(node, answerValue);
71+
5172
default:
5273
return this.answerDefault(node, answerValue);
5374
}

packages/scenario/test/bind-types.test.ts

+138-2
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,14 @@ describe('Data (<bind type>) type support', () => {
3737
t('implicit-string-value', 'implicit string'),
3838
t('int-value', '123'),
3939
t('decimal-value', '45.67'),
40+
t('geopoint-value', '38.25146813817506 21.758421137528785 0 0'),
4041
)
4142
),
4243
bind('/root/string-value').type('string').relevant(modelNodeRelevanceExpression),
4344
bind('/root/implicit-string-value').relevant(modelNodeRelevanceExpression),
4445
bind('/root/int-value').type('int').relevant(modelNodeRelevanceExpression),
45-
bind('/root/decimal-value').type('decimal').relevant(modelNodeRelevanceExpression)
46+
bind('/root/decimal-value').type('decimal').relevant(modelNodeRelevanceExpression),
47+
bind('/root/geopoint-value').type('geopoint').relevant(modelNodeRelevanceExpression)
4648
)
4749
),
4850
body(
@@ -182,6 +184,39 @@ describe('Data (<bind type>) type support', () => {
182184
expect(answer.value).toBe(null);
183185
});
184186
});
187+
188+
describe('type="geopoint"', () => {
189+
let answer: ModelValueNodeAnswer<'geopoint'>;
190+
191+
beforeEach(() => {
192+
answer = getTypedModelValueNodeAnswer('/root/geopoint-value', 'geopoint');
193+
});
194+
195+
it('has a (nullable) structured geopoint static type', () => {
196+
interface ExpectedGeopointValue {
197+
readonly latitude: number;
198+
readonly longitude: number;
199+
readonly altitude: number | null;
200+
readonly accuracy: number | null;
201+
}
202+
expectTypeOf(answer.value).toEqualTypeOf<ExpectedGeopointValue | null>();
203+
});
204+
205+
it('has a GeopointValue populated value', () => {
206+
expect(answer.value).toEqual({
207+
accuracy: 0,
208+
altitude: 0,
209+
latitude: 38.25146813817506,
210+
longitude: 21.758421137528785,
211+
});
212+
});
213+
214+
it('has an null as blank value', () => {
215+
scenario.answer(modelNodeRelevancePath, 'no');
216+
answer = getTypedModelValueNodeAnswer('/root/geopoint-value', 'geopoint');
217+
expect(answer.value).toBeNull();
218+
});
219+
});
185220
});
186221

187222
describe('inputs', () => {
@@ -202,12 +237,14 @@ describe('Data (<bind type>) type support', () => {
202237
t('implicit-string-value', 'implicit string'),
203238
t('int-value', '123'),
204239
t('decimal-value', '45.67'),
240+
t('geopoint-value', '38.25146813817506 21.758421137528785 1000 25'),
205241
)
206242
),
207243
bind('/root/string-value').type('string').relevant(inputRelevanceExpression),
208244
bind('/root/implicit-string-value').relevant(inputRelevanceExpression),
209245
bind('/root/int-value').type('int').relevant(inputRelevanceExpression),
210-
bind('/root/decimal-value').type('decimal').relevant(inputRelevanceExpression)
246+
bind('/root/decimal-value').type('decimal').relevant(inputRelevanceExpression),
247+
bind('/root/geopoint-value').type('geopoint').relevant(inputRelevanceExpression)
211248
)
212249
),
213250
body(
@@ -216,6 +253,7 @@ describe('Data (<bind type>) type support', () => {
216253
input('/root/implicit-string-value'),
217254
input('/root/int-value'),
218255
input('/root/decimal-value'),
256+
input('/root/geopoint-value'),
219257
)
220258
);
221259

@@ -480,6 +518,104 @@ describe('Data (<bind type>) type support', () => {
480518
});
481519
});
482520
});
521+
522+
describe('type="geopoint"', () => {
523+
let answer: InputNodeAnswer<'geopoint'>;
524+
525+
beforeEach(() => {
526+
answer = getTypedInputNodeAnswer('/root/geopoint-value', 'geopoint');
527+
});
528+
529+
it('has a (nullable) structured geopoint static type', () => {
530+
interface ExpectedGeopointValue {
531+
readonly latitude: number;
532+
readonly longitude: number;
533+
readonly altitude: number | null;
534+
readonly accuracy: number | null;
535+
}
536+
expectTypeOf(answer.value).toEqualTypeOf<ExpectedGeopointValue | null>();
537+
});
538+
539+
it('has a GeopointValue populated value', () => {
540+
expect(answer.value).toEqual({
541+
latitude: 38.25146813817506,
542+
longitude: 21.758421137528785,
543+
altitude: 1000,
544+
accuracy: 25,
545+
});
546+
expect(answer.stringValue).toEqual('38.25146813817506 21.758421137528785 1000 25');
547+
});
548+
549+
it('has an null as blank value', () => {
550+
scenario.answer(inputRelevancePath, 'no');
551+
answer = getTypedInputNodeAnswer('/root/geopoint-value', 'geopoint');
552+
expect(answer.value).toBeNull();
553+
expect(answer.stringValue).toBe('');
554+
});
555+
556+
it('sets altitude with value zero', () => {
557+
scenario.answer('/root/geopoint-value', '-5.299 46.663 0 5');
558+
answer = getTypedInputNodeAnswer('/root/geopoint-value', 'geopoint');
559+
expect(answer.value).toEqual({
560+
latitude: -5.299,
561+
longitude: 46.663,
562+
altitude: 0,
563+
accuracy: 5,
564+
});
565+
expect(answer.stringValue).toEqual('-5.299 46.663 0 5');
566+
});
567+
568+
it.each([
569+
'ZYX %% ABC $$',
570+
'ZYX %% 1200 10',
571+
'-15.2936673 120.7260063 ABC $$',
572+
'-2.33373 36.7260063 ABC 15',
573+
'20.2936673 -16.7260063 1200 ABCD',
574+
'99 179.99999 1200 0',
575+
'89.999 180.1111 1300 0',
576+
])('has null when incorrect value is passed', (expression) => {
577+
scenario.answer('/root/geopoint-value', expression);
578+
answer = getTypedInputNodeAnswer('/root/geopoint-value', 'geopoint');
579+
expect(answer.value).toBeNull();
580+
expect(answer.stringValue).toBe('');
581+
});
582+
583+
it.each([
584+
{
585+
expression: { latitude: 20.663, longitude: 16.763 },
586+
expectedAsObject: { latitude: 20.663, longitude: 16.763, altitude: null, accuracy: null },
587+
expectedAsText: '20.663 16.763',
588+
},
589+
{
590+
expression: { latitude: 19.899, longitude: 100.55559, accuracy: 15 },
591+
expectedAsObject: { latitude: 19.899, longitude: 100.55559, altitude: 0, accuracy: 15 },
592+
expectedAsText: '19.899 100.55559 0 15',
593+
},
594+
{
595+
expression: { latitude: 45.111, longitude: 127.23, altitude: 1350 },
596+
expectedAsObject: { latitude: 45.111, longitude: 127.23, altitude: 1350, accuracy: null },
597+
expectedAsText: '45.111 127.23 1350',
598+
},
599+
{
600+
expression: { latitude: 14.66599, longitude: 179.9009, altitude: 200, accuracy: 5 },
601+
expectedAsObject: { latitude: 14.66599, longitude: 179.9009, altitude: 200, accuracy: 5 },
602+
expectedAsText: '14.66599 179.9009 200 5',
603+
},
604+
{
605+
expression: { latitude: 0, longitude: 0, altitude: 0, accuracy: 0 },
606+
expectedAsObject: null,
607+
expectedAsText: '',
608+
},
609+
])(
610+
'sets value with GeopointValue object',
611+
({ expression, expectedAsObject, expectedAsText }) => {
612+
scenario.answer('/root/geopoint-value', expression);
613+
answer = getTypedInputNodeAnswer('/root/geopoint-value', 'geopoint');
614+
expect(answer.value).toEqual(expectedAsObject);
615+
expect(answer.stringValue).toEqual(expectedAsText);
616+
}
617+
);
618+
});
483619
});
484620

485621
describe('casting fractional values to int', () => {

packages/scenario/test/smoketests/child-vaccination.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -760,7 +760,7 @@ describe('ChildVaccinationTest.java', () => {
760760

761761
scenario.next('/data/not_single');
762762
scenario.next('/data/not_single/gps');
763-
scenario.answer('1.234 5.678');
763+
scenario.answer('1.234 5.678 0 2.3'); // an accuracy of 0m or greater than 5m makes a second geopoint question relevant
764764
scenario.next('/data/building_name');
765765
scenario.answer('Some building');
766766
scenario.next('/data/full_address1');

0 commit comments

Comments
 (0)