-
Notifications
You must be signed in to change notification settings - Fork 22
fix(#805): allow labels with computed media urls #806
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| '@getodk/xforms-engine': patch | ||
| '@getodk/web-forms': patch | ||
| --- | ||
|
|
||
| Fixed bug where computed image urls blocked form loading |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| <?xml version="1.0"?> | ||
| <h:html xmlns="http://www.w3.org/2002/xforms" | ||
| xmlns:h="http://www.w3.org/1999/xhtml" | ||
| xmlns:ev="http://www.w3.org/2001/xml-events" | ||
| xmlns:xsd="http://www.w3.org/2001/XMLSchema" | ||
| xmlns:jr="http://openrosa.org/javarosa" | ||
| xmlns:orx="http://openrosa.org/xforms" | ||
| xmlns:odk="http://www.opendatakit.org/xforms"> | ||
| <h:head> | ||
| <h:title>images-choice</h:title> | ||
| <model odk:xforms-version="1.0.0"> | ||
| <itext> | ||
| <translation lang="default" default="true()"> | ||
| <text id="/data/note:label"> | ||
| <value>Reference image</value> | ||
| <value form="image"> jr://images/<output value=" /data/animal "/> </value> | ||
| </text> | ||
| </translation> | ||
| </itext> | ||
| <instance> | ||
| <data id="images-choice" version="20240611120218"> | ||
| <animal/> | ||
| <note/> | ||
| <meta> | ||
| <instanceID/> | ||
| </meta> | ||
| </data> | ||
| </instance> | ||
| <instance id="animals"> | ||
| <root> | ||
| <item> | ||
| <itextId>animals-0</itextId> | ||
| <name>tiger</name> | ||
| <img>tiger.jpg</img> | ||
| </item> | ||
| <item> | ||
| <itextId>animals-1</itextId> | ||
| <name>camel</name> | ||
| <img>camel.jpg</img> | ||
| </item> | ||
| </root> | ||
| </instance> | ||
| <bind nodeset="/data/animal" type="string"/> | ||
| <bind nodeset="/data/meta/instanceID" type="string" readonly="true()" jr:preload="uid"/> | ||
| </model> | ||
| </h:head> | ||
| <h:body> | ||
| <select1 ref="/data/animal"> | ||
| <label>Animal</label> | ||
| <itemset nodeset="instance('animals')/root/item"> | ||
| <value ref="img"/> | ||
| <label ref="img"/> | ||
| </itemset> | ||
| </select1> | ||
| <input ref="/data/note"> | ||
| <label ref="jr:itext('/data/note:label')"/> | ||
| </input> | ||
| </h:body> | ||
| </h:html> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| import type { JRResourceURL } from '@getodk/common/jr-resources/JRResourceURL.ts'; | ||
| import { UnreachableError } from '@getodk/common/lib/error/UnreachableError.ts'; | ||
| import { | ||
| bind, | ||
| body, | ||
| head, | ||
| html, | ||
| input, | ||
| mainInstance, | ||
| model, | ||
| t, | ||
| title, | ||
| } from '@getodk/common/test-utils/xform-dsl/index.ts'; | ||
| import type { XFormsElement } from '@getodk/common/test-utils/xform-dsl/XFormsElement.ts'; | ||
| import type { TextRange } from '@getodk/xforms-engine'; | ||
| import { describe, expect, it } from 'vitest'; | ||
| import { Scenario } from '../src/jr/Scenario.ts'; | ||
|
|
||
| describe('`<label>` with media', () => { | ||
| const FORMS = ['image', 'video', 'audio'] as const; | ||
| type Form = (typeof FORMS)[number]; | ||
|
|
||
| const getSource = (label: TextRange<'label'>, form: Form): JRResourceURL | undefined => { | ||
| if (form === 'image') { | ||
| return label.imageSource; | ||
| } | ||
| if (form === 'video') { | ||
| return label.videoSource; | ||
| } | ||
| if (form === 'audio') { | ||
| return label.audioSource; | ||
| } | ||
| throw new UnreachableError(form); | ||
| }; | ||
|
|
||
| const getFilePath = (form: Form) => { | ||
| if (form === 'image') { | ||
| return 'jr://images/'; | ||
| } | ||
| if (form === 'video') { | ||
| return 'jr://images/'; | ||
| } | ||
| if (form === 'audio') { | ||
| return 'jr://images/'; | ||
| } | ||
| throw new UnreachableError(form); | ||
| }; | ||
|
|
||
| const init = async (translationValue: XFormsElement) => { | ||
| return await Scenario.init( | ||
| 'media-image', | ||
| html( | ||
| head( | ||
| title('media'), | ||
| model( | ||
| t( | ||
| 'itext', | ||
| t( | ||
| 'translation lang="default"', | ||
| t('text id="/data/name:label"', t('value', 'Name'), translationValue) | ||
| ) | ||
| ), | ||
| mainInstance(t('data id="media"', t('name'))), | ||
| bind('/data/name').type('string') | ||
| ) | ||
| ), | ||
| body(input('/data/name', t(`label ref="jr:itext('/data/name:label')"`))) | ||
| ) | ||
| ); | ||
| }; | ||
|
|
||
| for (const form of FORMS) { | ||
| describe(`form: "${form}"`, () => { | ||
| it('includes the URL', async () => { | ||
| const file = getFilePath(form) + 'my-file.ext'; | ||
| const scenario = await init(t(`value form="${form}"`, file)); | ||
|
|
||
| scenario.next('/data/name'); | ||
| const label = scenario.getQuestionLabel({ | ||
| assertCurrentReference: '/data/name', | ||
| }); | ||
|
|
||
| expect(label.asString).to.equal('Name'); | ||
| const source = getSource(label, form); | ||
| expect(source?.toString()).toEqual(file); | ||
| }); | ||
|
|
||
| it('calculates the URL', async () => { | ||
| const filename = 'my-file.ext'; | ||
| const path = getFilePath(form); | ||
| const scenario = await init( | ||
| t(`value form="${form}"`, ` ${path}<output value=" /data/name "/> `) | ||
| ); | ||
|
|
||
| scenario.next('/data/name'); | ||
| scenario.answer('/data/name', filename); | ||
|
|
||
| const label = scenario.getQuestionLabel({ | ||
| assertCurrentReference: '/data/name', | ||
| }); | ||
|
|
||
| expect(label.asString).to.equal('Name'); | ||
| const source = getSource(label, form); | ||
| expect(source?.toString()).to.equal(path + filename); | ||
| }); | ||
| }); | ||
| } | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,29 +20,47 @@ interface ChunksAndMedia { | |
| mediaSources: MediaSources; | ||
| } | ||
|
|
||
| const generateResourceChunk = (context: EvaluationContext, child: Element, type: ResourceType) => { | ||
| const parts = []; | ||
| for (const grandchild of child.childNodes) { | ||
| if (isElementNode(grandchild)) { | ||
| const expression = TextChunkExpression.fromOutput(grandchild); | ||
| if (expression) { | ||
| parts.push(createComputedExpression(context, expression)()); | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the crux of the change - by evaluating the output element the reactive code updates when the value changes. |
||
| } | ||
| } else if (isTextNode(grandchild)) { | ||
| parts.push(grandchild.data); | ||
| } | ||
| } | ||
| const url = parts.join('') as JRResourceURLString; | ||
| return TextChunkExpression.fromResource(url, type); | ||
| }; | ||
|
|
||
| const generateChunk = (node: Node): TextChunkExpression<'string'> | null => { | ||
| if (isElementNode(node)) { | ||
| return TextChunkExpression.fromOutput(node); | ||
| } | ||
| if (isTextNode(node)) { | ||
| const formAttribute = node.parentElement!.getAttribute('form') as ResourceType; | ||
| if (isResourceType(formAttribute)) { | ||
| return TextChunkExpression.fromResource(node.data as JRResourceURLString, formAttribute); | ||
| } | ||
| return TextChunkExpression.fromLiteral(node.data); | ||
| } | ||
| return null; | ||
| }; | ||
|
|
||
| const generateChunksForTranslation = ( | ||
| context: EvaluationContext, | ||
| textElement: Element | ||
| ): Array<TextChunkExpression<'string'>> => { | ||
| const chunks = []; | ||
| for (const child of textElement.childNodes) { | ||
| for (const grandchild of child.childNodes) { | ||
| const chunk = generateChunk(grandchild); | ||
| if (chunk) { | ||
| chunks.push(chunk); | ||
| for (const child of textElement.children) { | ||
| const formAttribute = child.getAttribute('form') as ResourceType; | ||
| if (isResourceType(formAttribute)) { | ||
| chunks.push(generateResourceChunk(context, child, formAttribute)); | ||
| } else { | ||
| for (const grandchild of child.childNodes) { | ||
| const chunk = generateChunk(grandchild); | ||
| if (chunk) { | ||
| chunks.push(chunk); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
@@ -62,7 +80,7 @@ const getChunkExpressions = <Role extends TextRole>( | |
| }); | ||
| const lang = context.getActiveLanguage(); | ||
| const elem = definition.form.model.getItextElement(lang, itextId); | ||
| return elem ? generateChunksForTranslation(elem) : []; | ||
| return elem ? generateChunksForTranslation(context, elem) : []; | ||
| }; | ||
|
|
||
| /** | ||
|
|
@@ -83,9 +101,10 @@ const createTextChunks = <Role extends TextRole>( | |
| const chunkExpressions = getChunkExpressions(context, definition); | ||
| chunkExpressions.forEach((chunkExpression) => { | ||
| if (chunkExpression.resourceType) { | ||
| mediaSources[chunkExpression.resourceType] = JRResourceURL.from( | ||
| chunkExpression.stringValue as JRResourceURLString | ||
| ); | ||
| const url = chunkExpression.stringValue?.trim(); | ||
| if (JRResourceURL.isJRResourceReference(url)) { | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This validation step is important... if you call |
||
| mediaSources[chunkExpression.resourceType] = JRResourceURL.from(url); | ||
| } | ||
| return; | ||
| } | ||
|
|
||
|
|
||
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.
Without this the first time through media was successfully loaded but because it's async it never showed because the message took precedence.