8000 feat(#347): adds support for select with images - engine part by latin-panda · Pull Request #399 · getodk/web-forms · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

feat(#347): adds support for select with images - engine part #399

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

Merged
merged 39 commits into from
May 23, 2025

Conversation

latin-panda
Copy link
Collaborator
@latin-panda latin-panda commented May 9, 2025

Closes #347

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

  • Chrome
  • Firefox
  • Safari (macOS)
  • Safari (iOS)
  • Chrome for Android
  • Not applicable

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

CI passes successfully, and manual testing of existing forms in preview.

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

Retrieving media type and media URL

This update modifies the XPath itext function to return a node-set instead of a single string, allowing retrieval of multiple value nodes associated with an itext. This enables support for output nodes, where child nodes (like output) can be accessed within each value node. The engine can then iterate over these child nodes, understand their attributes, giving more flexibility in handling complex XML structures.

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?

No visual impact for users yet, everything should still work as expected.

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

I think existing forms in preview and CI are covering most of it. But what other tricky scenario is there?

What's changed

  • Replaces the XPath's evaluation function for itext from string to node-set.
  • Extracts some code from the secondary instance's attachment to the abstract class (FormAttachmentResource)
  • Added support for images when creating Text Range

Copy link
changeset-bot bot commented May 9, 2025

🦋 Changeset detected

Latest commit: 11fbc00

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/xpath Minor
@getodk/web-forms Patch
@getodk/scenario Patch
@getodk/common 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

Copy link
Member
@lognaturel lognaturel left a comment

Choose a reason for hiding this comment

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

I've only scrubbed through quickly but here are a few things that caught my eye!

t(
'translation lang="default"',
t('text id="static_instance-cities-0"', t('value', 'Montréal')),
t('text id="static_instance-cities-1"', t('value', 'Grenoble'))
Copy link
Member

Choose a reason for hiding this comment

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

I would have added another language and verified that when you switch the language the /data/city_name value updates. But maybe that's already tested elsewhere?

I think we should also consider introducing a failing test that's just like this one but there are additional itext forms (e.g. image). The results should be the same but I believe currently the jr://images url will be concatenated to the label text. That's a limitation we're accepting for now and a failing test would document it effectively.

Copy link
Member
@lognaturel lognaturel May 9, 2025

Choose a reason for hiding this comment

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

Ok, I see language switching is well covered at jr-itext.test.ts. I think this still adds something because it has a real calculate in the form definition and is making sure that's wired up correctly. Maybe it should live in the jr-itext test suite? How did you decide to put it here? It's certainly logical. Are there other functions tested here?

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, I can add more tests for that.

How did you decide to put it here? It's certainly logical. Are there other functions tested here?

I thought, "Can the calculate work with itext?" Then I would go to the calculate test suite to check.

In L76, there's something about accessing text, but no other functions inside calculate

	bind('/data/calc')
								.type('string')
								.calculate("instance('instance')/root/item[value = 'A'][count = /data/input]/id"),

But I can create a new test suite if it feels too weird here.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I would have added another language and verified that when you switch the language the /data/city_name value updates.

I've added an additional test case for that here.

for (const valueElement of domProvider.getChildrenByLocalName(textElement, 'value')) {
//
if (!domProvider.hasLocalNamedAttribute(valueElement, 'form')) {
return domProvider.getNodeValue(valueElement);
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Fact: This is where the actual XPath query happens; it returned the first value nodes that didn't have the form attribute.
Now, it returns all value nodes, and they are evaluated in the engine (see createTextRange file)


export const jr = new FunctionLibrary(JAVAROSA_NAMESPACE_URI, [...Object.values(string)]);
export const jr = new FunctionLibrary(JAVAROSA_NAMESPACE_URI, [...Object.values(nodeSet)]);
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Fact: Currently, there can only be one function for itext. I tried leaving string and node-set, but it just keeps one. Knowing that itext will always return one data type from the XPath layer could be a good thing.

@@ -15,19 +14,20 @@ const isOutputElement = (element: Element): element is OutputElement => {
return element.localName === 'output' && element.hasAttribute('value');
};

export class TextChunkExpression extends DependentExpression<'string'> {
export class TextChunkExpression<T extends 'nodes' | 'string'> extends DependentExpression<T> {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Once we add all the support for output, markdown, HTML styling, etc., this could naturally drop the string and only be TextChunkExpression<'nodes'> extend DependentExpression {. I need to experiment with those scenarios to confirm.

@@ -279,4 +297,14 @@ export class PrimaryInstance<

return Promise.resolve(result);
}

loadMediaResources(resourceURL: string): Promise<MediaResource> {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This class provides simple functions to access high-level information, like the active language, and could be extended to retrieve media files, such as images or videos. That's why I added the loadMediaResources function here.

To optimize performance, it avoids querying all media files during the initial LoadForm phase, as finding media URLs requires traversing a complex node tree. Instead, it leverages the engine’s XML processing phase when it creates an instance, where it already navigates the node tree to prepare data for all question types. By collecting media information during this process, the engine avoids a second, redundant tree traversal.

Later, we can revisit and find a way to retrieve all media URLs at the initial LoadForm phase.

@@ -74,10 +138,18 @@ export const createTextRange = <Role extends TextRole>(
definition: TextRangeDefinition<Role>
): ComputedFormTextRange<Role> => {
return context.scope.runTask(() => {
const getTextChunks = createTextChunks(context, definition.chunks);
const textChunks = createTextChunks(context, definition.chunks);
const evaluator = isEngineXPathEvaluator(context.evaluator) ? context.evaluator : 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 type static-check enforces this validation. I tested several forms (all question types form, all note form, all select form, etc). When creating a Text Range, the evaluator always has a root node that is a Primary Instance.
But, to play safe, we are leaving this validation.

Copy link
Member
@lognaturel lognaturel May 12, 2025

Choose a reason for hiding this comment

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

This looks like a no-op to me! Is it just confusing the type checker or am I missing something? This is essentially saying if context.evaluator is null, use null, otherwise use context.evaluator, isn't it? Let's try hard to do away with null here. As far as I understand it, if there isn't an evaluator, nothing works so we should halt.

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 evaluator, in this case, is only used for retrieving media attachments. I've added it as part of the media context, so its role is clearer in the code.

@latin-panda
Copy link
Collaborator Author

Can you help me understand when image requests happens a little bit better? Here's what I think happens:

Sure! @lognaturel thanks for the feedback! Let me know if you have any other suggestion

My comments below:

When a client gets to a node that has an image and accesses it, the engine will request the image

Yes, a Vue component can make the request, for example, for each select's option: options.forEach(option => await option.label.image)

Another idea I'm playing around with is letting the engine's interface do the requests, so the Vue component calls one function that retrieves all images: const images: Array<Blob> = await question.getAllImages()

I'm making this as part of the front-end work.

Currently with our Vue client, that means all the requests for the active language will happen in parallel when the form is first rendered

Correct, the requests are in parallel. Going back to the previous question:

  • If the Vue component handles the request, it will render the images as soon as they are available.
  • If the engine's client interface handles the request (await question.getAllImages()), it will wait for all images of a question to be available before passing the array of images to the Vue component.

Usually, the first one is preferred, as it displays content as quickly as possible. In the select with images context, it will still be better the first option, so the user sees that it is loading (in slow connections) instead of waiting with an empty state. What do you think?

If the user switches languages, images for the new language will be requested

Yes, it only requests images from the active language. When a new language is chosen, it requests the images for that language.

The behavior when an image is missing depends on the "missing attachment" behavior defined. If it's set to be BLANK the image will just be blank. Otherwise a 404 will be thrown and the form won't work.

Yes, we have a placeholder for when the image can't be loaded.

@latin-panda latin-panda marked this pull request as ready for review May 13, 2025 03:30
@latin-panda latin-panda requested a review from lognaturel May 21, 2025 22:08
Primarily because it avilds nested generics
@@ -19,6 +19,8 @@ export interface SelectItem {
export type SelectValueOptions = readonly SelectItem[];

export interface SelectNodeState extends BaseValueNodeState<readonly string[]> {
get isSelectWithImages(): boolean;
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I think this will go away, and not changes in this file

@@ -51,6 +51,7 @@ interface SelectControlStateSpec extends ValueNodeStateSpec<readonly string[]> {
readonly label: Accessor<TextRange<'label'> | null>;
readonly hint: Accessor<TextRange<'hint'> | null>;
readonly valueOptions: Accessor<SelectValueOptions>;
readonly isSelectWithImages: Accessor<boolean>;
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

All these changes might be reverted and handled in the front-end side

@@ -118,12 +118,6 @@ export type TextOrigin =
| 'form-derived'
| 'engine';

export interface TextMediaSource {
10000
Copy link
Member

Choose a reason for hiding this comment

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

Not part of what the client sees so shouldn't be in client.

get audioSource(): JRResourceURL | null;
get videoSource(): JRResourceURL | null;

get imageSource(): JRResourceURL | undefined;
Copy link
Member

Choose a reason for hiding this comment

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

I ended up with undefined here mostly because of how MediaSources gets populated. Semantically I think null would be fine here.

} else {
// translation expression evaluates to an entire itext block, process forms separately
computed.forEach((itextForm) => {
if (isEngineXPathElement(itextForm) && itextForm instanceof StaticElement) {
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 there are types that would let us identify it as an itext form but I couldn't immediately figure that out and I think this reads well enough for now.

@@ -145,7 +89,8 @@ export const createTextRange = <Role extends TextRole>(
const textChunks = createTextChunks(context, definition.chunks);

return createMemo(() => {
Copy link
Member

Choose a reason for hiding this comment

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

There are three nested memos here. That may be optimal or it may add overhead, I'm not sure. I think it's something we can explore in the future. My hunch is that we'll want to revise all of this again pretty soon.

@@ -19,7 +19,7 @@ export class ItemsetLabelDefinition extends TextRangeDefinition<'item-label'> {
}

readonly role = 'item-label';
readonly chunks: TextChunkExpression<'nodes' | 'string'>[];
readonly chunks: ReadonlyArray<TextChunkExpression<'nodes' | 'string'>>;
Copy link
Member

Choose a reason for hiding this comment

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

When I ran lint locally it flagged all of these array declarations on complex types. It didn't seem to block the build in CI though so maybe there's an inconsistency to investigate at some point. Seems like using the array types with a type with generics is consistent and idiomatic so I went with it.

@lognaturel lognaturel force-pushed the select-with-images branch from c6a2271 to ef32d5b Compare May 23, 2025 18:04
@lognaturel
Copy link
Member

We've made a high-level decision to pass JR URLs through to the client for media. This is a departure from how external secondary instances and submission media attachments are handled but it's a deliberate choice based on the current lack of need to have the engine do any media processing. This could change in the future but is consistent with the JavaRosa/Collect split. JR URLs are exposed through the TextRange client API. That naming doesn't feel as appropriate anymore but we want to learn more about the evolution needed around these concepts before renaming things.

There's some kind of API test missing here: we should test that on language changes, the JR URLs on TextRange change appropriately. We'll defer that since we know we have more to do in this area. There aren't really any edge cases here -- it either works or doesn't -- so we can verify this in the Vue client for now.

We've gone with a minimally-invasive approach. The jr:itext function is now a nodeset function. When the language changes, the jr:itext function call that defines a form element's text is made again, getting the new language's itext block. The itext block is split into its different "form"s (default, image, video, audio) at evaluation time.

There are some things we know are uncomfortable:

  • itext block parsing feels like it should be done at parse time
  • the string | nodes return type on TextChunkExpression suggests there's more than one concept there. Indeed, a translation expression is very different from a reference expression or literal text
  • TextRange feels like the wrong name for something that can contain media

We'll continue to work towards addressing those in future work.

@lognaturel lognaturel force-pushed the select-with-images branch from e9c2ad2 to 11fbc00 Compare May 23, 2025 19:21
Copy link
Member
@lognaturel lognaturel left a comment

Choose a reason for hiding this comment

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

I put some notes on how I'm thinking about this work at #399 (comment)

I haven't tried this with a frontend and there are no API tests so I think it's important to hook things up before merging to do a quick sanity check, @latin-panda

Other than that, I'm comfortable with merging!

@lognaturel
Copy link
Member

I thought the changes from 11fbc00 would be confusing once we squashed so I reverted them for now. We could apply them as a separate set of changes or wait until we are in that part of the code again. The latter would be my preference because I think we'll understand a lot more by then and may choose a different direction.

* should create a {@link: TextElementDefinition}, so that createTextChunks function can
* request the computed value to XPath and create the TextChunk.
*/
const processValueNodeChildren = (item: EngineXPathNode) => {
Copy link
Member

Choose a reason for hiding this comment

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

If I recall correctly, this was introduced to re-establish passable behavior for itext default forms with outputs in them. I don't think it's necessary for this PR or if we do want to do it here we should introduce a test related to it.

@latin-panda latin-panda merged commit f0e2745 into main May 23, 2025
47 checks passed
@latin-panda latin-panda deleted the select-with-images branch May 23, 2025 20:10
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.

Add selects with images
2 participants
0