Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(#249): Adds default geopoint question type #300

Merged
merged 54 commits into from
Mar 3, 2025

Conversation

latin-panda
Copy link
Collaborator

@latin-panda latin-panda commented Feb 6, 2025

Closes #249

I have verified this PR works in these browsers (latest versions):

  • Chrome
  • Firefox
  • Safari (macOS)
  • Safari (iOS) (@lognaturel confirmed that it's working)
  • Chrome for Android (Need to serve using HTTPS to test, I used ngrok)
  • Not applicable

What else has been done to verify that this works as intended?

The test plan is here
The e2e tests are here

Test videos:

Test case: Responsive design and capturing good accuracy
reponsive-with-good-accuracy.mp4
Test case: Responsive design and capturing poor accuracy
responsive-and-poor-accuracy.mp4
Test case: Having multiple fields and retry capturing location
multiple-fields-and-retry.mp4
Test case: Autosave and no autosave
autosave-and-no-autosave.mp4
Test case: Permission denied and required field
permission-denied-and-required-field.mp4
Test case: Field becoming relevant and disabled
field-becoming-relevant-and-disabled.mp4
Test case: reacts to form translation and Chrome, Firefox and Safari

Geopoint's text translation isn't supported yet.

Chrome-firefox-safari.mp4
Test case: Geopoint note type
geopoint-note-type.mov
Test case: Android phone
android.mp4

Edit [27 Feb]: We changed to 3 decimals maximum and made a space between the number and the meter unit. Screenshot shows update:

Why is this the best possible solution? Were any other approaches considered?

  • A separate component in the Web Forms client now handles the readonly representation of Geopoint data. This component is reused in both Geopoint Note and Geopoint Input, ensuring consistent display of the same data.
  • The Geopoint Codec now provides more robust support for Geopoint data tuples with type checking, that clarifies the expected representation of this data.

How does this change affect users? Describe intentional changes to behavior and behavior that could have accidentally been affected by code changes. In other words, what are the regression risks?

This work should support the default behavior of Geopoint input and no regression are expected.

Do we need any specific form for testing your changes? If so, please attach one.

The test forms are at the end of this comment

What's changed

  • Adds a demo form for geopoint: 1-geopoint.xml
  • Adds a geopoint note in the note's demo form: 2-all-possible-notes.xml
  • Adds new scenario tests for geopoint input (bind-types.test.ts), there are already some scenario smoke tests working with geopoint.
  • Adds a geopoint input and geopoint readonly components in web-forms (vue client)
  • Registers geopoint in the input node and in note node
  • Adds a geopoint codec
  • Adds node options concept for question attributes - see input control

Copy link

changeset-bot bot commented Feb 6, 2025

🦋 Changeset detected

Latest commit: bf60966

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 5 packages
Name Type
@getodk/xforms-engine Minor
@getodk/web-forms Minor
@getodk/scenario Minor
@getodk/common Patch
@getodk/xpath Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@@ -11,10 +12,18 @@ export class InputControlDefinition extends ControlDefinition<'input'> {

readonly type = 'input';
readonly appearances: InputAppearanceDefinition;
readonly rows: number | null;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rows implementation will be completed in this issue: #301

@latin-panda latin-panda force-pushed the adds-default-geopoint-question-type branch from 414d3ee to 001fdf8 Compare February 10, 2025 22:02
Comment on lines 156 to 158
// ToDo: if one is missing, it still need to know the position of the others.
// ToDo: Change the value type to object? or default to negative / zero number?
props.node.setValue([latitude ?? 0, longitude ?? 0, altitude ?? 0, accuracy ?? 0].join(' '));
Copy link
Collaborator Author

@latin-panda latin-panda Feb 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@eyelidlessness I'd like to know your opinion.

First question:
The order of these values in the string is essential for locating them correctly, avoiding confusion between altitude and accuracy.
I noticed that altitude isn't always available (e.g., when testing with WiFi on a desktop). To preserve the position, what do you think about setting a zero or null default value?
The other option is to assume that if only three values come in the string, the third one is accuracy and that altitude wasn't provided 🙁 (which needs to be explained in the documentation for anyone making a client that connects with the engine)

Second question:
We need to create a new codec that builds on the one in XPath you just merged and call it in getSharedValueCodec, correct? It looks like ValueTypePlaceholderCodec doesn’t handle this automatically and is just a placeholder.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First question.....

All the JR imported tests in the scenario suite use a string with the four values (latitude, longitude, altitude, accuracy), and zero is used.

Maybe zero is the way to go 🤔; I haven't found anything explicit about this in the documentation of XForms

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it might be worth taking a step back. The intent of the engine/client interface—in this case InputNode—is to provide a representation of an <input> (in ODK XForms terms) which is most useful for clients, e.g. to provide a UI. The intent of ValueCodec is to decode readable values in that representation (i.e. InputNode.currentState.value), and to encode that representation of a value back to a "node value" (for submission, as serialized XML; for evaluation in XPath expressions).

It's generally up to us what that representation will be. So far, we've usually determined that by choosing a platform type with the closest semantics to the ODK XForms type: e.g. int <-> bigint, decimal <-> number.

There's an added wrinkle in this case, because there are currently two representations of the same type (geopoint) of value:

  • In @getodk/xpath, decoding a geopoint node value as an object which is pretty much a subset of the web standard GeolocationCoordinates
  • In @getodk/xforms-engine, encoding a geopoint node value from a GeoJSON Point (see also the semi-official type definitions)

If we're going to share the encode/decode logic for these existing cases, it seems pretty likely (unless we have a good reason otherwise) that we're going to want a representation of the value which is either:

  1. One of the two we already use
  2. A combined interface which is a superset of both
  3. Some other representation entirely???

Option 1 pretty much designs itself:

import { Point as GeoJSONPoint } from 'geojson';

interface GeopointRuntimeValue {
  readonly latitude: number;
  readonly longitude: number;
  readonly altitude: number; // Or `number | null`?
  readonly accuracy: number; // Or `number | null`?
}

type GeopointInputValue =
  | GepointRuntimeValue
  | GeoJSONPoint;

class GeopointValueCodec extends ValueCodec<GepointRuntimeValue, GeopointInputValue> {
  /**
   * @todo Implementation will:
   *
   * 1. Decode `GeopointRuntimeValue`, pretty much reusing the logic in `@getodk/xpath`
   * 2. Encode `GeopointInputValue` by detecting whether input is a `GeoJSON` point or a `GeopointRuntimeValue`, and then:
   *     - if the former, reuse logic for GeoJSON external secondary instances
   *     - if the latter, invert the decode logic
   */
}

Coming back to the question of what to do when altitude isn't available, we can make it nullable (like it is in the web standard GeolocationCoordinates), or default to 0 ... in the runtime representation (GeopointRuntimeValue). There's trade-offs either way: nullable is closer to the platform interface, but we can't distinguish a node value's 0 as missing or sea level. In either case, we have to encode a missing value as 0 (as far as I know, the altitude substring isn't optional).

Option 2 (combined superset interface) would make decode a little more complex (assigning the same sub-values each in two places), and simplify the encode logic (only need to reuse logic from GeoJSON external secondary instances).

Option 3 (a completely new representation)... I'd be open to it, but I would want to justify it by referencing something that's successful in projects with strong geo background. But my gut tells me that most representations of a geopoint are going to look very similar to one of the representations we already use.


In any case, I'd like to see if we can settle this aspect of the design before going too much further with the client integration. Whatever the representation we end up with, I don't think that we want the client to be responsible for decoding or encoding node values, since that's an engine responsibility everywhere else.

Copy link
Collaborator Author

@latin-panda latin-panda Feb 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the chat earlier! This is a quick summary of the plan:

First round:
Build a normal geopoint's engine codec, in which runtime value representation is the shape of GeolocationCoordinates

Second round:
Evaluate the codec and see how to fit GeoJSON; maybe external secondary serialization to use the same encoding logic.

Third round:
Evaluate sharing between xpath and xform-engine packages.

After evaluating the second or third, we might decide that these are out of scope and create GH issues for those.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's fair to assume these followups are deferred for a later scope! And I've volunteered to come back to them. I'll probably want to tackle both followup concerns together, as it's likely the sharing solution will be useful for/applicable to other concerns relatively immediately (ahem #311).

I'm trying not to let my focus wander from this review, I'm nearing the end! Would you mind filing an issue to track following up on this (if someone hasn't already)?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second round:
Evaluate the codec and see how to fit GeoJSON; maybe external secondary serialization to use the same encoding logic.

#321

Third round:
Evaluate sharing between xpath and xform-engine packages.

#322

Copy link
Member

@eyelidlessness eyelidlessness left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned in Slack, I haven't finished a full pass. But I wanted to get you the actionable feedback I have now before I'm off for a break!

Improves test cases.
Exposes GeopointNoteValue and GeopointInputValue.
Renames component from InputGeopointReadonly to GeopointFormattedValue. And moves it one folder up.
Adds more variants to demo geopoint xml form
Copy link
Member

@eyelidlessness eyelidlessness left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I've been through a full pass top to bottom through the PR, although I have seen some changes and comments coming in as I've progressed. So I know that there's already more for me to look at. But I'm not sure how much more steam I have for the day, so I wanted to make sure I post the feedback I do have before I run out!

Comment on lines 78 to 84
if (altitude.value == null && accuracy.value != null) {
return [latitude, longitude, altitude, accuracy];
}

if (altitude.value != null && accuracy.value != null) {
return [latitude, longitude, altitude, accuracy];
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is logically equivalent to:

Suggested change
if (altitude.value == null && accuracy.value != null) {
return [latitude, longitude, altitude, accuracy];
}
if (altitude.value != null && accuracy.value != null) {
return [latitude, longitude, altitude, accuracy];
}
if (accuracy.value != null) {
return [latitude, longitude, altitude, accuracy];
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment on lines 94 to 100
const { latitude, longitude, altitude, accuracy } = this.internalValue;
const isLatitude = this.isValidDegrees('latitude', latitude.value);
const isLongitude = this.isValidDegrees('longitude', longitude.value);

if (!isLatitude || !isLongitude) {
return null;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just an observation, no action requested!

I hadn't thought about this case when I was pseudocoding out the tuple and semantic value concepts. Normally, I'd want to handle the invalid cases during (error) or prior to (return null from a factory) construction.

But I think this is the right call for now, please don't change anything about this yet! We'll need to revisit this as we incorporate the other codec use cases, and this leaves a good starting point to think about how to encode error conditions into the value (presumably within its internalValue), which will be important as Geopoint is composed into Geotrace and Geoshape (and potentially Line distinct from Geotrace, for the XPath cases).

@latin-panda
Copy link
Collaborator Author

@eyelidlessness, I've incorporated the state machine into the Vue component and refactored the Goepoint codec. Can you please have a look?

@latin-panda latin-panda marked this pull request as ready for review February 25, 2025 04:59
@latin-panda
Copy link
Collaborator Author

@eyelidlessness ready for review

@latin-panda
Copy link
Collaborator Author

The final manual test is complete.

@latin-panda
Copy link
Collaborator Author

latin-panda commented Feb 27, 2025

@eyelidlessness I pushed a small format change that Yaw, Hélène, and Aly requested.

  • Adds a space before meter unit: from 123m to 123 m (I didn't know it was the correct way)
  • Truncates only the accuracy to max three decimals

I've updated the mobile test case (PR description) with a screenshot showing this change.

Copy link
Member

@eyelidlessness eyelidlessness left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you! This is 🎉 AWESOME! 🎉

I've added comments about a last few little nitpicky things. I think at least a few are worth addressing, but I'm excited to land this so I'm pre-approving pending those remaining nits.

Feel free to merge when you're satisfied with any changes related to those last few comments!

🚀

Comment on lines 8 to 9
Support for default geopoint question type (`<input type="geopoint">`)
Support for geopoint note
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Support for default geopoint question type (`<input type="geopoint">`)
Support for geopoint note
- Support for geopoint questions with no appearance
- Support for geopoint notes

For future reference: there is no type attribute on XForms <input>. I assume this is colloquial shorthand, and I generally understand the intent. But since these changesets ultimately get glommed together into CHANGELOG.md (which is user-/contributor-facing), I think the precision is valuable here.

@@ -760,7 +760,7 @@ describe('ChildVaccinationTest.java', () => {

scenario.next('/data/not_single');
scenario.next('/data/not_single/gps');
scenario.answer('1.234 5.678');
scenario.answer('1.234 5.678 0 2.3');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consistent with getodk/javarosa#817

Suggested change
scenario.answer('1.234 5.678 0 2.3');
scenario.answer('1.234 5.678 0 2.3'); // an accuracy of 0m or greater than 5m makes a second geopoint question relevant

(Note: I tested this and it has to be an accuracy of at least 5.005 or so1 to fail. The affected relevant expression computes based on an accuracy value rounded to two decimals.)

Footnotes

  1. And "or so" probably varies ever so slightly between JavaRosa/Web Forms! Because floats. But I didn't dig in further than this.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For context: it's been helpful, as much as possible, to try to have as few source-level differences as we can in scenario test bodies between JavaRosa and Web Forms. In some cases it's impossible or really impractical for them to be the same, but trivial differences are a good way for us to get confused when we try to reconcile changes over time. (And this is one of the first tests of that I think! So I think it's a good idea to reinforce that precedent here.)

Comment on lines +3 to +7
export const GEOLOCATION_STATUS = {
PENDING: 'PENDING',
SUCCESS: 'SUCCESS',
FAILURE: 'FAILURE',
} as const;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not asking for a change here! I just want to make the observation for #296 that this approach is especially fussy in @getodk/web-forms because of the complicated limitations on imports between/from .vue modules. A plain union-of-string-literal types doesn't far a lot better in that regard, but it can be at least a little bit more flexible.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. It'd be nice to find something that can do a good type check but is also accessible with dot notation to not hardcode string, and the IDE can do the autocomplete – small things for the developer's heart😁

const value = computed((): TextRenderableValue => {
type NoteRenderableValue = GeopointNoteValue | TextRenderableValue;

const value = computed<NoteRenderableValue>(() => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have strong feelings about this either way, but I'm curious if there's reasoning behind moving the callback return type annotation into the type parameter position. As in...

// Is there a reason to favor this:
computed<NoteRenderableValue>(() => { /* ... */ })

// ... over this:
computed((): NoteRenderableValue => { /* ... */ })

I think my instinct is usually the latter, and I almost always try to let TypeScript infer type parameters unless it does it wrong for some reason. But I'm always open to refining these kinds of opinions!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both should be fine, but the first one is closer to the definition of computed:

Screenshot 2025-03-03 at 8 42 15 AM

Comment on lines 72 to 80
<template v-if="question.valueType === 'geopoint'">
<GeopointFormattedValue :question="question" />
</template>

<template v-else-if="value != null">
<div class="note-value">
{{ value }}
</div>
</template>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently the presence of a .note-value is determined having any non-null value. It's a nitpick, but I think that's worth preserving e.g. for long term consistent application of styles, stable querying in tests, etc.

Suggested change
<template v-if="question.valueType === 'geopoint'">
<GeopointFormattedValue :question="question" />
</template>
<template v-else-if="value != null">
<div class="note-value">
{{ value }}
</div>
</template>
<div v-if="value != null" class="note-value">
<template v-if="question.valueType === 'geopoint'">
<GeopointFormattedValue :question="question" />
</template>
<template v-else>
{{ value }}
</template>
</div>

Comment on lines 1 to 18
export const truncateDecimals = (num: number, decimals = 3): string => {
if (
typeof num !== 'number' ||
Number.isNaN(num) ||
typeof decimals !== 'number' ||
Number.isNaN(decimals)
) {
return '';
}

if (Number.isInteger(num) || decimals <= 0) {
return num.toString();
}

const factor = Math.pow(10, decimals);
const withDecimals = Math.floor(Math.abs(num) * factor) / factor;
return (num < 0 ? -withDecimals : withDecimals).toString();
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just want to be totally sure I understand the intent here (and maybe it would be helpful for this function to have a JSDoc clarifying/confirming). Is the motivation for this all of...?

  • Display a finite integer as-is
  • Display a finite fractional number with decimal values up to the specified decimal
  • Otherwise display an empty string

I see this is currently only used for accuracy values. If we really want something this general now, I'd suggest:

  • An options object is clearer at the call site than two number arguments
  • There isn't really a sensible default for something this general
  • We don't need to check runtime types if we trust ourselves not to bypass the static type checker
  • We shouldn't accept/silently ignore invalid decimal options. If we don't trust ourselves to pass valid values (which is reasonable for something general since it might compose in unexpected ways!), an error probably makes more sense
  • Checking for a finite value doubles as a NaN check (and likewise handles Infinity/-Infinity the same way)
  • I also frequently forget Math.sign exists, but this time I remembered!

We could bikeshed this a lot further depending on how general we expect it to be, but I think these are at least moderately worthwhile nitpicks.

(Note: there's no harm in composing it with a default for all accuracy cases! Not necessary either IMO.)

Suggested change
export const truncateDecimals = (num: number, decimals = 3): string => {
if (
typeof num !== 'number' ||
Number.isNaN(num) ||
typeof decimals !== 'number' ||
Number.isNaN(decimals)
) {
return '';
}
if (Number.isInteger(num) || decimals <= 0) {
return num.toString();
}
const factor = Math.pow(10, decimals);
const withDecimals = Math.floor(Math.abs(num) * factor) / factor;
return (num < 0 ? -withDecimals : withDecimals).toString();
};
interface TruncateDecimalsOptions {
readonly decimals: number;
}
export const truncateDecimals = (num: number, options: TruncateDecimalsOptions): string => {
const { decimals } = options;
if (!Number.isFinite(decimals) || decimals < 0) {
throw new Error(
`Truncation failed: expected a finite, non-negative decimal option, got: ${decimals}`
);
}
if (!Number.isFinite(num)) {
return '';
}
if (Number.isInteger(num)) {
return num.toString();
}
const factor = Math.pow(10, decimals);
const withDecimals = Math.floor(Math.abs(num) * factor) / factor;
const sign = Math.sign(num);
return String(withDecimals * sign);
};

@@ -8,6 +8,7 @@
"tests",
"tests-examples",
"src/components/**/*.vue",
"src/components/**/*.ts",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've been wondering when this would happen haha!

}

return parsed;
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't want to send this through another round trip, just for future reference: we usually use PascalCase modules names as 1:1 with a class and/or interface of the same name. If I were creating this module I'd probably kebab-case it.

Copy link
Collaborator Author

@latin-panda latin-panda Mar 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I saw some files with kebab-case; I thought those were "old" files and new ones use PascalCase. In previous projects I've been in, they used kebab-case for all files or PascalCase for all files.
Now it makes sense, I've fixed this.

Copy link
Collaborator Author

@latin-panda latin-panda Mar 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also see a couple of filenames with cammelCase like this selectOptionId. Is there a reason?

}

const parsed = Number(value);
if (typeof value !== 'string' || value.trim() === '' || isNaN(parsed)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Up to you if you want to change this to

Suggested change
if (typeof value !== 'string' || value.trim() === '' || isNaN(parsed)) {
if (typeof value !== 'string' || value.trim() === '' || !Number.isFinite(parsed)) {

Note: in general I think for just NaN checking, Number.isNaN is preferred over the global isNaN. But if you don't want to do the suggested change I'd just leave as-is. This is really something we should have a lint rule for.

Comment on lines 156 to 158
// ToDo: if one is missing, it still need to know the position of the others.
// ToDo: Change the value type to object? or default to negative / zero number?
props.node.setValue([latitude ?? 0, longitude ?? 0, altitude ?? 0, accuracy ?? 0].join(' '));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's fair to assume these followups are deferred for a later scope! And I've volunteered to come back to them. I'll probably want to tackle both followup concerns together, as it's likely the sharing solution will be useful for/applicable to other concerns relatively immediately (ahem #311).

I'm trying not to let my focus wander from this review, I'm nearing the end! Would you mind filing an issue to track following up on this (if someone hasn't already)?

…file, fixes changeset, improves notes template
@latin-panda latin-panda merged commit 99295eb into main Mar 3, 2025
74 checks passed
@latin-panda latin-panda deleted the adds-default-geopoint-question-type branch March 3, 2025 16:39
spwoodcock pushed a commit to hotosm/web-forms that referenced this pull request Mar 12, 2025
- 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Form filling: Geopoint with no appearance
2 participants