Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/rich-teams-push.md
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
59 changes: 59 additions & 0 deletions packages/common/src/fixtures/select/7-images-choice-computed.xml
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>
108 changes: 108 additions & 0 deletions packages/scenario/test/label-media.test.ts
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
Expand Up @@ -63,6 +63,7 @@ const loadMedia = async (src?: JRResourceURL): Promise<void> => {
const setMedia = (value: string) => {
mediaUrl.value = value;
loading.value = false;
errorMessage.value = '';

Copy link
Copy Markdown
Collaborator Author

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.

};

const handleError = (error: string) => {
Expand Down
45 changes: 32 additions & 13 deletions packages/xforms-engine/src/lib/reactivity/text/createTextRange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)());

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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);
}
}
}
}
Expand All @@ -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) : [];
};

/**
Expand All @@ -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)) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This validation step is important... if you call JRResourceURL.from with an invalid URL it throws an Error which can be fatal. If this is based on user input, or at load time the input is blank, then we should just handle it gracefully.

mediaSources[chunkExpression.resourceType] = JRResourceURL.from(url);
}
return;
}

Expand Down