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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
65c8b94
initial setup
latin-panda May 5, 2025
f6dce4c
Merge branch 'main' of https://github.com/getodk/web-forms into selec…
latin-panda May 5, 2025
418a894
Support nodeset in createTextRange
latin-panda May 6, 2025
cd4d2dc
Fixes reactivity when language changes
latin-panda May 6, 2025
7bc6605
Fixes for processing text chunks with result type string
latin-panda May 7, 2025
bad1225
Fixes for processing text chunks when they have children nodes
latin-panda May 7, 2025
73b350b
Fixes lint
latin-panda May 7, 2025
c894e38
Fixes type checks
latin-panda May 8, 2025
1c2d6b3
Fixes web-forms tests
latin-panda May 8, 2025
4682e07
Fixes web-forms tests
latin-panda May 8, 2025
c9eff33
Adds scenario test for calculate using jr:itext function
latin-panda May 8, 2025
2fdc371
Prepares fetch attachment code for reusability and extension
latin-panda May 8, 2025
a8e1497
Support for media attachments
latin-panda May 8, 2025
81cbb8e
Add the capability to fetch media resources to the primary instance
latin-panda May 8, 2025
5fe4313
Add accessor for images in TextRange
latin-panda May 9, 2025
15f61d8
Makes checks stricter
latin-panda May 9, 2025
474ad19
Adds file extensions
latin-panda May 9, 2025
959e5d 8000 0
Fetches sample forms' attachments in preview page running in local en…
latin-panda May 9, 2025
dfa911c
Fixes lint and typing
latin-panda May 9, 2025
45ec08a
Polishing code
latin-panda May 9, 2025
21e4caa
Polishing code
latin-panda May 9, 2025
8820c6f
Changeset
latin-panda May 12, 2025
6479027
Add more test cases for itext
latin-panda May 12, 2025
725d34a
Adds support for gif and svg
latin-panda May 12, 2025
e4d676c
Feedback: no nulls
latin-panda May 12, 2025
c7a2b76
Feedback: removes type guards and puts back switch
latin-panda May 13, 2025
5e5b467
Polish code
latin-panda May 13, 2025
4891b42
LN contribution: remove generics in form attachments
latin-panda May 14, 2025
d011a8b
Trying out the idea of moving fetch to SelectControl.ts
latin-panda May 14, 2025
c5e0c02
lint
latin-panda May 14, 2025
9a6c13a
Cleans createTextRange
latin-panda May 15, 2025
49762c4
Adds accessors for other media types for completeness
latin-panda May 15, 2025
457b16b
Detects when it's a select with images
latin-panda May 15, 2025
03b90fa
Merge branch 'main' of https://github.com/getodk/web-forms into selec…
latin-panda May 20, 2025
55e8130
Refactored engine - not fetching media directly
latin-panda May 21, 2025
b2cb130
Subjective style: [] feels easier to read than Array
lognaturel May 21, 2025
ef32d5b
Simplify processing itext result
lognaturel May 23, 2025
3a4e769
Reorder tests
lognaturel May 23, 2025
11fbc00
Revert changes related to form attachments
lognaturel May 23, 2025
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
9 changes: 9 additions & 0 deletions .changeset/afraid-bottles-buy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@getodk/xforms-engine': minor
'@getodk/xpath': minor
'@getodk/web-forms': patch
'@getodk/scenario': patch
'@getodk/common': patch
---

Adds support for downloading image attachments referenced in a form's IText
14 changes: 12 additions & 2 deletions packages/common/src/fixtures/import-glob-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { IS_NODE_RUNTIME } from '../env/detection.ts';

interface GlobURLFetchResponse {
text(): Promise<string>;
blob(): Promise<Blob>;
}

type FetchGlobURL = (globURL: string) => Awaitable<GlobURLFetchResponse>;
Expand All @@ -22,6 +23,11 @@ if (IS_NODE_RUNTIME) {
text(): Promise<string> {
return readFile(this.fsPath, 'utf-8');
}

async blob(): Promise<Blob> {
const buffer = await readFile(this.fsPath);
return new Blob([buffer]);
}
}

fetchGlobURL = (globURL) => {
Expand All @@ -33,7 +39,7 @@ if (IS_NODE_RUNTIME) {

type ImportMetaGlobURLRecord = Readonly<Record<string, string>>;

export type GlobFixtureLoader = (this: void) => Promise<string>;
export type GlobFixtureLoader = (this: void) => Promise<Blob | string>;

export interface GlobFixture {
readonly url: URL;
Expand All @@ -46,7 +52,11 @@ const globFixtureLoader = (globURL: string): GlobFixtureLoader => {
return async () => {
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 import helper is used for the Preview Page and the scenario tests.

const response = await fetchGlobURL(globURL);

return response.text();
const textExtensions = ['.xml', '.csv', '.geojson'];
if (textExtensions.some((ext) => globURL.endsWith(ext))) {
return response.text();
}
return response.blob();
};
};

Expand Down
Binary file added packages/common/src/fixtures/select/camel.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added packages/common/src/fixtures/select/tiger.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 22 additions & 0 deletions packages/common/src/fixtures/xform-attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ const xformAttachmentFileExtensions = [
'.xml',
'.xml.example',
'.xlsx',
'.png',
'.jpg',
'.jpeg',
'.gif',
'.svg',
] as const;

type XFormAttachmentFileExtensions = typeof xformAttachmentFileExtensions;
Expand Down Expand Up @@ -86,6 +91,23 @@ export class XFormAttachmentFixture {
this.mimeType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
break;

case '.png':
this.mimeType = 'image/png';
break;

case '.jpg':
case '.jpeg':
this.mimeType = 'image/jpeg';
break;

case '.gif':
this.mimeType = 'image/gif';
break;

case '.svg':
this.mimeType = 'image/svg+xml';
break;

default:
throw new UnreachableError(fileExtension);
}
Expand Down
4 changes: 2 additions & 2 deletions packages/common/src/fixtures/xforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ const extractURLIdentifier = (remoteUrl: URL): string => {
return match?.[1] ?? '';
};

type LoadXFormXML = () => Promise<string>;
type LoadXFormXML = () => Promise<Blob | string>;

const xformURLLoader = (url: URL): LoadXFormXML => {
return async () => {
Expand All @@ -102,7 +102,7 @@ export class XFormResource<Type extends XFormResourceType> {
const service = new JRResourceService();
const parentPath = localPath.replace(/\/[^/]+$/, '');

service.activateFixtures(parentPath, ['file', 'file-csv']);
service.activateFixtures(parentPath, ['file', 'file-csv', 'images']);
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 is used to load files (forms and attachments) from the common package on the Preview Page when running the project in the local environment. I'm unsure yet what it needs to load attachments on the Preview Page when deployed in central.


return service;
},
Expand Down
2 changes: 1 addition & 1 deletion packages/common/src/jr-resources/JRResource.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { XFormAttachmentFixture } from '../fixtures/xform-attachments.ts';
import { JRResourceURL } from './JRResourceURL.ts';

type JRResourceLoader = (this: void) => Promise<string>;
type JRResourceLoader = (this: void) => Promise<Blob | string>;

export interface JRResourceSource {
readonly url: JRResourceURL;
Expand Down
16 changes: 11 additions & 5 deletions packages/scenario/src/jr/resource/ResourcePathHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,17 @@ const testFixtures = await Promise.all(
return [];
}

return fixture.loadXML().then((fixtureXML) => ({
identifier,
localPath,
fixtureXML,
}));
return fixture.loadXML().then((fixtureXML) => {
if (typeof fixtureXML !== 'string') {
throw new Error('Wrong XML Form type. Expected a string');
}

return {
identifier,
localPath,
fixtureXML,
};
});
})
);

Expand Down
75 changes: 75 additions & 0 deletions packages/scenario/test/calculate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@ import {
html,
input,
instance,
item,
mainInstance,
model,
select1,
t,
title,
} from '@getodk/common/test/fixtures/xform-dsl/index.ts';
import type { ExpectStatic } from 'vitest';
import { describe, expect, it } from 'vitest';
import { intAnswer } from '../src/answer/ExpectedIntAnswer.ts';
import { stringAnswer } from '../src/answer/ExpectedStringAnswer.ts';
import { Scenario } from '../src/jr/Scenario.ts';

describe('TriggerableDagTest.java', () => {
Expand Down Expand Up @@ -164,3 +167,75 @@ describe('MultiplePredicateTest.java', () => {
});
});
});

describe('jr:itext function in calculate expressions', () => {
it('should retrieve the correct itext value', async () => {
const scenario = await Scenario.init(
'Itext with calculation',
html(
head(
title('Itext with calculation'),
model(
mainInstance(
t('data id="dynamic-choices-predicates"', t('country'), t('city'), t('city_name'))
),

t(
'itext',
t(
'translation lang="default"',
t('text id="static_instance-cities-0"', t('value', 'Montreal')),
t('text id="static_instance-cities-1"', t('value', 'Marseille'))
),
t(
'translation lang="es"',
t('text id="static_instance-cities-0"', t('value', 'Montréal')),
t('text id="static_instance-cities-1"', t('value', 'Marsella'))
)
),

t(
'instance id="cities"',
t(
'root',
t(
'item',
t('itextId', 'static_instance-cities-0'),
t('name', 'montreal'),
t('country', 'canada')
),
t(
'item',
t('itextId', 'static_instance-cities-1'),
t('name', 'marseille'),
t('country', 'france')
)
)
),
bind('/data/country').type('string'),
bind('/data/city_name')
.type('string')
.calculate(
"if(/data/country ='canada', jr:itext(instance('cities')/root/item[name='montreal']/itextId), jr:itext(instance('cities')/root/item[name='marseille']/itextId))"
)
)
),
body(select1('/data/country', item('canada', 'Canada'), item('france', 'France')))
)
);

scenario.answer('/data/country', 'france');
expect(scenario.answerOf('/data/city_name')).toEqualAnswer(stringAnswer('Marseille'));

scenario.answer('/data/country', 'canada');
expect(scenario.answerOf('/data/city_name')).toEqualAnswer(stringAnswer('Montreal'));

scenario.setLanguage('es');

scenario.answer('/data/country', 'france');
expect(scenario.answerOf('/data/city_name')).toEqualAnswer(stringAnswer('Marsella'));

scenario.answer('/data/country', 'canada');
expect(scenario.answerOf('/data/city_name')).toEqualAnswer(stringAnswer('Montréal'));
});
});
4 changes: 4 additions & 0 deletions packages/web-forms/src/demo/FormPreview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ if (route.query.url) {
xformResource
?.loadXML()
.then((formXML) => {
if (typeof formXML !== 'string') {
throw new Error('Wrong XML Form type. Expected a string');
}

formPreviewState.value = {
formXML,
fetchFormAttachment: xformResource.fetchFormAttachment,
Expand Down
14 changes: 12 additions & 2 deletions packages/web-forms/tests/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@ export const getWebFormsTestFixture = (identifier: string): Promise<string> => {
throw new Error(`Could not find web-forms test fixture with identifier: ${identifier}`);
}

return fixture.loadXML();
return fixture.loadXML().then((xml) => {
if (typeof xml !== 'string') {
throw new Error('Wrong XML Form type. Expected a string');
}
return xml;
});
};

export const getFormXml = (fileName: string): Promise<string> => {
Expand All @@ -41,7 +46,12 @@ export const getFormXml = (fileName: string): Promise<string> => {
throw new Error(`Could not find fixture with file name: ${fileName}`);
}

return fixture.loadXML();
return fixture.loadXML().then((xml) => {
if (typeof xml !== 'string') {
throw new Error('Wrong XML Form type. Expected a string');
}
return xml;
});
};

export const getReactiveForm = async (formPath: string): Promise<RootNode> => {
Expand Down
2 changes: 2 additions & 0 deletions packages/xforms-engine/src/client/SelectNode.ts
Original file line number Diff line number Diff line change
10000 Expand Up @@ -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


get children(): null;

get valueOptions(): readonly SelectItem[];
Expand Down
6 changes: 5 additions & 1 deletion packages/xforms-engine/src/client/TextRange.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { JRResourceURL } from '@getodk/common/jr-resources/JRResourceURL.ts';
import type { ActiveLanguage } from './FormLanguage.ts';
import type { RootNodeState } from './RootNode.ts';

/**
* **COMMENTARY**
Expand Down Expand Up @@ -156,4 +156,8 @@ export interface TextRange<Role extends TextRole, Origin extends TextOrigin = Te

get asString(): string;
get formatted(): unknown;

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.

get audioSource(): JRResourceURL | undefined;
get videoSource(): JRResourceURL | undefined;
}
6 changes: 6 additions & 0 deletions packages/xforms-engine/src/instance/SelectControl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

}

export class SelectControl
Expand Down Expand Up @@ -109,6 +110,10 @@ export class SelectControl

const valueOptions = createItemCollection(this);

const isSelectWithImages = this.scope.runTask(() => {
return createMemo(() => valueOptions().some((item) => !!item.label.imageSource));
});

const mapOptionsByValue: Accessor<SelectItemMap> = this.scope.runTask(() => {
return createMemo(() => {
return new Map(valueOptions().map((item) => [item.value, item]));
Expand Down Expand Up @@ -150,6 +155,7 @@ export class SelectControl
valueOptions,
value: valueState,
instanceValue: this.getInstanceValue,
isSelectWithImages,
},
this.instanceConfig
);
Expand Down
22 changes: 21 additions & 1 deletion packages/xforms-engine/src/instance/text/TextRange.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { JRResourceURL } from '@getodk/common/jr-resources/JRResourceURL.ts';
import type {
TextRange as ClientTextRange,
TextChunk,
Expand All @@ -6,6 +7,12 @@ import type {
} from '../../client/TextRange.ts';
import { FormattedTextStub } from './FormattedTextStub.ts';

export interface MediaSources {
image?: JRResourceURL;
video?: JRResourceURL;
audio?: JRResourceURL;
}

export class TextRange<Role extends TextRole, Origin extends TextOrigin>
implements ClientTextRange<Role, Origin>
{
Expand All @@ -21,9 +28,22 @@ export class TextRange<Role extends TextRole, Origin extends TextOrigin>
return this.chunks.map((chunk) => chunk.asString).join('');
}

get imageSource(): JRResourceURL | undefined {
return this.mediaSources?.image;
}

get audioSource(): JRResourceURL | undefined {
return this.mediaSources?.audio;
}

get videoSource(): JRResourceURL | undefined {
return this.mediaSources?.video;
}

constructor(
readonly origin: Origin,
readonly role: Role,
protected readonly chunks: readonly TextChunk[]
protected readonly chunks: readonly TextChunk[],
protected readonly mediaSources?: MediaSources
) {}
}
Loading
0