-
Notifications
You must be signed in to change notification settings - Fork 13
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
Conversation
🦋 Changeset detectedLatest commit: bf60966 The changes in this PR will be included in the next version bump. This PR includes changesets to release 5 packages
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 |
packages/xforms-engine/src/parse/body/control/InputControlDefinition.ts
Outdated
Show resolved
Hide resolved
@@ -11,10 +12,18 @@ export class InputControlDefinition extends ControlDefinition<'input'> { | |||
|
|||
readonly type = 'input'; | |||
readonly appearances: InputAppearanceDefinition; | |||
readonly rows: number | null; |
There was a problem hiding this comment.
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
This reverts commit f447f13.
414d3ee
to
001fdf8
Compare
// 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(' ')); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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 ageopoint
node value as an object which is pretty much a subset of the web standardGeolocationCoordinates
- In
@getodk/xforms-engine
, encoding ageopoint
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:
- One of the two we already use
- A combined interface which is a superset of both
- 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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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)?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this 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!
packages/web-forms/src/components/controls/Input/InputGeopoint.vue
Outdated
Show resolved
Hide resolved
packages/web-forms/src/components/controls/Input/InputGeopoint.vue
Outdated
Show resolved
Hide resolved
packages/web-forms/src/components/controls/Input/InputGeopoint.vue
Outdated
Show resolved
Hide resolved
packages/web-forms/src/components/controls/Input/InputGeopoint.vue
Outdated
Show resolved
Hide resolved
packages/web-forms/src/components/controls/Input/InputGeopoint.vue
Outdated
Show resolved
Hide resolved
packages/web-forms/src/components/controls/Input/InputGeopoint.vue
Outdated
Show resolved
Hide resolved
There was a problem hiding this 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!
if (altitude.value == null && accuracy.value != null) { | ||
return [latitude, longitude, altitude, accuracy]; | ||
} | ||
|
||
if (altitude.value != null && accuracy.value != null) { | ||
return [latitude, longitude, altitude, accuracy]; | ||
} |
There was a problem hiding this comment.
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:
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]; | |
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
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; | ||
} |
There was a problem hiding this comment.
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).
@eyelidlessness, I've incorporated the state machine into the Vue component and refactored the Goepoint codec. Can you please have a look? |
@eyelidlessness ready for review |
The final manual test is complete.
|
@eyelidlessness I pushed a small format change that Yaw, Hélène, and Aly requested.
I've updated the mobile test case (PR description) with a screenshot showing this change. |
There was a problem hiding this 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!
🚀
.changeset/violet-chefs-dress.md
Outdated
Support for default geopoint question type (`<input type="geopoint">`) | ||
Support for geopoint note |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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'); |
There was a problem hiding this comment.
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
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
-
And "or so" probably varies ever so slightly between JavaRosa/Web Forms! Because floats. But I didn't dig in further than this. ↩
There was a problem hiding this comment.
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.)
export const GEOLOCATION_STATUS = { | ||
PENDING: 'PENDING', | ||
SUCCESS: 'SUCCESS', | ||
FAILURE: 'FAILURE', | ||
} as const; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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>(() => { |
There was a problem hiding this comment.
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!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
<template v-if="question.valueType === 'geopoint'"> | ||
<GeopointFormattedValue :question="question" /> | ||
</template> | ||
|
||
<template v-else-if="value != null"> | ||
<div class="note-value"> | ||
{{ value }} | ||
</div> | ||
</template> |
There was a problem hiding this comment.
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.
<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> |
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(); | ||
}; |
There was a problem hiding this comment.
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 handlesInfinity
/-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.)
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", |
There was a problem hiding this comment.
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; | ||
}; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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)) { |
There was a problem hiding this comment.
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
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.
// 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(' ')); |
There was a problem hiding this comment.
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
- 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.
Closes #249
I have verified this PR works in these browsers (latest versions):
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?
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