diff --git a/.changeset/nasty-impalas-talk.md b/.changeset/nasty-impalas-talk.md
new file mode 100644
index 000000000..ecaa7906b
--- /dev/null
+++ b/.changeset/nasty-impalas-talk.md
@@ -0,0 +1,8 @@
+---
+'@getodk/xforms-engine': minor
+'@getodk/web-forms': minor
+'@getodk/scenario': minor
+'@getodk/common': minor
+---
+
+Add support for all jr:preload options
diff --git a/README.md b/README.md
index 7ef2fec24..2ec9ba7db 100644
--- a/README.md
+++ b/README.md
@@ -33,7 +33,7 @@ This section is auto generated. Please update `feature-matrix.json` and then run
- ##### Question types (basic functionality)
🟩🟩🟩🟩🟩🟩⬜⬜⬜⬜⬜⬜⬜⬜⬜ 41\%
+ ##### Question types (basic functionality)
🟩🟩🟩🟩🟩🟩🟩🟩🟩⬜⬜⬜⬜⬜⬜ 61\%
@@ -66,13 +66,13 @@ This section is auto generated. Please update `feature-matrix.json` and then run
| rank | ✅ |
| csv-external | ✅ |
| acknowledge | 🚧 |
-| start | |
-| end | |
-| today | |
-| deviceid | |
-| username | |
-| phonenumber | |
-| email | |
+| start | ✅ |
+| end | ✅ |
+| today | ✅ |
+| deviceid | ✅ |
+| username | ✅ |
+| phonenumber | ✅ |
+| email | ✅ |
| audit | |
diff --git a/feature-matrix.json b/feature-matrix.json
index 4ca50d1b1..48fb7582e 100644
--- a/feature-matrix.json
+++ b/feature-matrix.json
@@ -26,13 +26,13 @@
"rank": "✅",
"csv-external": "✅",
"acknowledge": "🚧",
- "start": "",
- "end": "",
- "today": "",
- "deviceid": "",
- "username": "",
- "phonenumber": "",
- "email": "",
+ "start": "✅",
+ "end": "✅",
+ "today": "✅",
+ "deviceid": "✅",
+ "username": "✅",
+ "phonenumber": "✅",
+ "email": "✅",
"audit": ""
},
"Appearances": {
diff --git a/packages/common/src/fixtures/test-javarosa/resources/preload.xml b/packages/common/src/fixtures/test-javarosa/resources/preload.xml
new file mode 100644
index 000000000..b7d4d1d37
--- /dev/null
+++ b/packages/common/src/fixtures/test-javarosa/resources/preload.xml
@@ -0,0 +1,56 @@
+
+
+
+ jr:preload
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/common/src/test/fixtures/xform-dsl/BindBuilderXFormsElement.ts b/packages/common/src/test/fixtures/xform-dsl/BindBuilderXFormsElement.ts
index 4a8d303dc..3805bb062 100644
--- a/packages/common/src/test/fixtures/xform-dsl/BindBuilderXFormsElement.ts
+++ b/packages/common/src/test/fixtures/xform-dsl/BindBuilderXFormsElement.ts
@@ -1,3 +1,4 @@
+import { JAVAROSA_PREFIX } from '../../../constants/xmlns.ts';
import { EmptyXFormsElement } from './EmptyXFormsElement.ts';
import type { XFormsElement } from './XFormsElement.ts';
@@ -56,9 +57,11 @@ class BindBuilderXFormsElement implements XFormsElement {
}
preload(expression: string): BindBuilderXFormsElement {
- this.bindAttributes.set('jr:preload', expression);
+ return this.withAttribute(JAVAROSA_PREFIX, 'preload', expression);
+ }
- return this;
+ preloadParams(expression: string): BindBuilderXFormsElement {
+ return this.withAttribute(JAVAROSA_PREFIX, 'preloadParams', expression);
}
readonly(expression = 'true()'): BindBuilderXFormsElement {
diff --git a/packages/scenario/src/client/init.ts b/packages/scenario/src/client/init.ts
index 303b565b6..2333e89f4 100644
--- a/packages/scenario/src/client/init.ts
+++ b/packages/scenario/src/client/init.ts
@@ -7,6 +7,7 @@ import type {
LoadFormWarningResult,
MissingResourceBehavior,
OpaqueReactiveObjectFactory,
+ PreloadProperties,
RootNode,
} from '@getodk/xforms-engine';
import { createInstance } from '@getodk/xforms-engine';
@@ -27,6 +28,7 @@ export interface TestFormOptions {
readonly missingResourceBehavior: MissingResourceBehavior;
readonly stateFactory: OpaqueReactiveObjectFactory;
readonly instanceAttachments: InstanceAttachmentsConfig;
+ readonly preloadProperties: PreloadProperties;
}
const defaultConfig = {
@@ -62,6 +64,7 @@ export const initializeTestForm = async (
instance: {
stateFactory: options.stateFactory,
instanceAttachments: options.instanceAttachments,
+ preloadProperties: options.preloadProperties,
},
});
});
diff --git a/packages/scenario/src/jr/Scenario.ts b/packages/scenario/src/jr/Scenario.ts
index d276e65a5..4b11abc25 100644
--- a/packages/scenario/src/jr/Scenario.ts
+++ b/packages/scenario/src/jr/Scenario.ts
@@ -174,6 +174,7 @@ export class Scenario {
fileNameFactory: ({ basename, extension }) => `${basename}${extension ?? ''}`,
...overrideOptions?.instanceAttachments,
},
+ preloadProperties: overrideOptions?.preloadProperties ?? {},
};
}
diff --git a/packages/scenario/test/jr-preload.test.ts b/packages/scenario/test/jr-preload.test.ts
index 51e28128b..49fd2ac99 100644
--- a/packages/scenario/test/jr-preload.test.ts
+++ b/packages/scenario/test/jr-preload.test.ts
@@ -9,45 +9,187 @@ import {
t,
title,
} from '@getodk/common/test/fixtures/xform-dsl/index.ts';
+import { Temporal } from 'temporal-polyfill';
import { describe, expect, it } from 'vitest';
import { Scenario } from '../src/jr/Scenario.ts';
+const CENTRAL_DATE_FORMAT_REGEX = /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/;
+const CENTRAL_DATETIME_FORMAT_REGEX =
+ /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]+[+|-][0-9]{2}:[0-9]{2}$/;
+
describe('`jr:preload`', () => {
- // ported from: https://github.com/getodk/javarosa/blob/2dd8e15e9f3110a86f8d7d851efc98627ae5692e/src/test/java/org/javarosa/core/model/utils/test/QuestionPreloaderTest.java#L23
- it('preloads specified data in bound elements', async () => {
- const scenario = await Scenario.init(
- 'Preload attribute',
- html(
- head(
- title('Preload element'),
- model(
- mainInstance(t('data id="preload-attribute"', t('element'))),
- bind('/data/element').preload('uid')
- )
- ),
- body(input('/data/element'))
- )
- );
+ describe('uid', () => {
+ // ported from: https://github.com/getodk/javarosa/blob/2dd8e15e9f3110a86f8d7d851efc98627ae5692e/src/test/java/org/javarosa/core/model/utils/test/QuestionPreloaderTest.java#L23
+ it('preloads specified data in bound elements', async () => {
+ const scenario = await Scenario.init(
+ 'Preload attribute',
+ html(
+ head(
+ title('Preload element'),
+ model(
+ mainInstance(t('data id="preload-attribute"', t('element'))),
+ bind('/data/element').preload('uid')
+ )
+ ),
+ body(input('/data/element'))
+ )
+ );
+
+ expect(scenario.answerOf('/data/element')).toStartWith('uuid:');
+ });
+
+ // ported from: https://github.com/getodk/javarosa/blob/2dd8e15e9f3110a86f8d7d851efc98627ae5692e/src/test/java/org/javarosa/core/model/utils/test/QuestionPreloaderTest.java#L43
+ it('preloads specified data in bound attributes', async () => {
+ const scenario = await Scenario.init(
+ 'Preload attribute',
+ html(
+ head(
+ title('Preload attribute'),
+ model(
+ mainInstance(t('data id="preload-attribute"', t('element attr=""'))),
+ bind('/data/element/@attr').preload('uid')
+ )
+ ),
+ body(input('/data/element'))
+ )
+ );
+
+ expect(scenario.attributeOf('/data/element', 'attr')).toStartWith('uuid:');
+ });
+ });
+
+ describe('datetime', () => {
+ it('preloads timestamp start', async () => {
+ const start = Temporal.Now.instant().epochNanoseconds;
+ const scenario = await Scenario.init(
+ 'Preload start date',
+ html(
+ head(
+ title('Preload start date'),
+ model(
+ mainInstance(t('data id="preload-attribute"', t('element'))),
+ bind('/data/element').type('xsd:dateTime').preload('timestamp').preloadParams('start')
+ )
+ ),
+ body()
+ )
+ );
+ const end = Temporal.Now.instant().epochNanoseconds;
+ const val = scenario.answerOf('/data/element').toString();
+ const actual = Temporal.Instant.from(val).epochNanoseconds;
+
+ expect(actual).toBeGreaterThanOrEqual(start);
+ expect(actual).toBeLessThanOrEqual(end);
- expect(scenario.answerOf('/data/element')).toStartWith('uuid:');
+ expect(val).toMatch(CENTRAL_DATETIME_FORMAT_REGEX);
+ });
+
+ it('preloads date today', async () => {
+ const start = Temporal.Now.plainDateISO();
+ const scenario = await Scenario.init(
+ 'Preload start date',
+ html(
+ head(
+ title('Preload start date'),
+ model(
+ mainInstance(t('data id="preload-attribute"', t('element'))),
+ bind('/data/element').type('xsd:date').preload('date').preloadParams('today')
+ )
+ ),
+ body()
+ )
+ );
+ const end = Temporal.Now.plainDateISO();
+
+ expect(scenario.answerOf('/data/element').toString()).toSatisfy((actual: string) => {
+ const actualDate = Temporal.PlainDate.from(actual);
+ expect(actual).toMatch(CENTRAL_DATE_FORMAT_REGEX);
+ return actualDate.equals(start) || actualDate.equals(end); // just in case this test runs at midnight...
+ });
+ });
+
+ it('preloads timestamp end', async () => {
+ const scenario = await Scenario.init(
+ 'Preload end date',
+ html(
+ head(
+ title('Preload end date'),
+ model(
+ mainInstance(t('data id="preload-attribute"', t('element'))),
+ bind('/data/element').type('xsd:dateTime').preload('timestamp').preloadParams('end')
+ )
+ ),
+ body()
+ )
+ );
+ expect(scenario.answerOf('/data/element').toString()).toEqual(''); // doesn't trigger until submission
+
+ const start = Temporal.Now.instant().epochNanoseconds;
+ await scenario.prepareWebFormsInstancePayload();
+ const xml = scenario.proposed_serializeInstance();
+ const end = Temporal.Now.instant().epochNanoseconds;
+ const timestampElement = /(.*)<\/element>/g.exec(xml);
+ if (!timestampElement || timestampElement.length < 2 || !timestampElement[1]) {
+ throw new Error('element not found');
+ }
+
+ const val = timestampElement[1];
+
+ const actual = Temporal.Instant.from(val).epochNanoseconds;
+ expect(actual).toBeGreaterThanOrEqual(start);
+ expect(actual).toBeLessThanOrEqual(end);
+
+ expect(val).toMatch(CENTRAL_DATETIME_FORMAT_REGEX);
+ });
});
- // ported from: https://github.com/getodk/javarosa/blob/2dd8e15e9f3110a86f8d7d851efc98627ae5692e/src/test/java/org/javarosa/core/model/utils/test/QuestionPreloaderTest.java#L43
- it('preloads specified data in bound attributes', async () => {
- const scenario = await Scenario.init(
- 'Preload attribute',
- html(
- head(
- title('Preload attribute'),
- model(
- mainInstance(t('data id="preload-attribute"', t('element attr=""'))),
- bind('/data/element/@attr').preload('uid')
- )
+ describe('property', () => {
+ it('bound from given properties', async () => {
+ const deviceID = '123456';
+ const email = 'my@email';
+ const username = 'mukesh';
+ const phoneNumber = '+15551234';
+
+ const scenario = await Scenario.init(
+ 'Properties',
+ html(
+ head(
+ title('Properties'),
+ model(
+ mainInstance(
+ t(
+ 'data id="properties"',
+ t('deviceid'),
+ t('email'),
+ t('username'),
+ t('phonenumber')
+ )
+ ),
+ bind('/data/deviceid').type('string').preload('property').preloadParams('deviceid'),
+ bind('/data/email').type('string').preload('property').preloadParams('email'),
+ bind('/data/username').type('string').preload('property').preloadParams('username'),
+ bind('/data/phonenumber')
+ .type('string')
+ .preload('property')
+ .preloadParams('phonenumber')
+ )
+ ),
+ body()
),
- body(input('/data/element'))
- )
- );
+ {
+ preloadProperties: {
+ deviceID,
+ email,
+ username,
+ phoneNumber,
+ },
+ }
+ );
- expect(scenario.attributeOf('/data/element', 'attr')).toStartWith('uuid:');
+ expect(scenario.answerOf('/data/deviceid').toString()).to.equal(deviceID);
+ expect(scenario.answerOf('/data/email').toString()).to.equal(email);
+ expect(scenario.answerOf('/data/username').toString()).to.equal(username);
+ expect(scenario.answerOf('/data/phonenumber').toString()).to.equal(phoneNumber);
+ });
});
});
diff --git a/packages/web-forms/e2e/page-objects/controls/InputControl.ts b/packages/web-forms/e2e/page-objects/controls/InputControl.ts
index 1a9b6c9e2..9e966e7e5 100644
--- a/packages/web-forms/e2e/page-objects/controls/InputControl.ts
+++ b/packages/web-forms/e2e/page-objects/controls/InputControl.ts
@@ -8,11 +8,9 @@ export class InputControl {
}
async getInputByLabel(label: string) {
- const container = this.page
- .locator('.question-container')
- .filter({ has: this.page.locator(`.control-text label:text-is("${label}")`) });
-
- const input = container.locator('input');
+ const input = this.page.locator(
+ `.question-container:has(.control-text label:text-is("${label}")) input`
+ );
await expect(input, `Input for label "${label}" not found`).toBeVisible();
await input.scrollIntoViewIfNeeded();
diff --git a/packages/web-forms/e2e/page-objects/controls/NoteControl.ts b/packages/web-forms/e2e/page-objects/controls/NoteControl.ts
new file mode 100644
index 000000000..3d61246e8
--- /dev/null
+++ b/packages/web-forms/e2e/page-objects/controls/NoteControl.ts
@@ -0,0 +1,37 @@
+import { expect, Page } from '@playwright/test';
+
+export class NoteControl {
+ private readonly page: Page;
+
+ constructor(page: Page) {
+ this.page = page;
+ }
+
+ getNoteContainer(label: string) {
+ return this.page.locator(`.question-container:has(.control-text label:text-is("${label}"))`);
+ }
+
+ async getNoteByLabel(label: string) {
+ const noteContainer = this.getNoteContainer(label);
+ const noteValue = noteContainer.locator('.note-value');
+ await expect(noteValue, `Note for label "${label}" not found`).toBeVisible();
+ await noteValue.scrollIntoViewIfNeeded();
+ return noteValue;
+ }
+
+ async getValue(label: string) {
+ const note = await this.getNoteByLabel(label);
+ return await note.innerText();
+ }
+
+ async expectValue(label: string, value: string) {
+ const note = await this.getNoteByLabel(label);
+ await expect(note, `Input for label "${label}" does not have expected value`).toHaveText(value);
+ }
+
+ async expectValueToBeEmpty(label: string) {
+ const noteContainer = this.getNoteContainer(label);
+ await expect(noteContainer).toBeVisible();
+ await expect(noteContainer.locator('.note-value')).not.toBeVisible();
+ }
+}
diff --git a/packages/web-forms/e2e/page-objects/pages/FillFormPage.ts b/packages/web-forms/e2e/page-objects/pages/FillFormPage.ts
index 5338527da..6bf01cb4d 100644
--- a/packages/web-forms/e2e/page-objects/pages/FillFormPage.ts
+++ b/packages/web-forms/e2e/page-objects/pages/FillFormPage.ts
@@ -2,6 +2,7 @@ import { Page } from '@playwright/test';
import { GeopointControl } from '../controls/GeopointControl.js';
import { InputControl } from '../controls/InputControl.js';
import { MapControl } from '../controls/MapControl.js';
+import { NoteControl } from '../controls/NoteControl.js';
import { RepeatControl } from '../controls/RepeatControl.js';
import { SelectControl } from '../controls/SelectControl.js';
import { TextControl } from '../controls/TextControl.js';
@@ -15,6 +16,7 @@ export class FillFormPage {
public readonly repeat: RepeatControl;
public readonly text: TextControl;
public readonly select: SelectControl;
+ public readonly note: NoteControl;
constructor(page: Page) {
this.page = page;
@@ -25,6 +27,7 @@ export class FillFormPage {
this.repeat = new RepeatControl(page);
this.text = new TextControl(page);
this.select = new SelectControl(page);
+ this.note = new NoteControl(page);
}
async copyToClipboard(valueToCopy: string) {
@@ -36,4 +39,8 @@ export class FillFormPage {
async waitForNetworkIdle() {
return this.page.waitForLoadState('networkidle');
}
+
+ async reload() {
+ await this.page.reload();
+ }
}
diff --git a/packages/web-forms/e2e/test-cases/functional/jr-preload.test.ts b/packages/web-forms/e2e/test-cases/functional/jr-preload.test.ts
new file mode 100644
index 000000000..01a692758
--- /dev/null
+++ b/packages/web-forms/e2e/test-cases/functional/jr-preload.test.ts
@@ -0,0 +1,63 @@
+import { expect, test } from '@playwright/test';
+import { FillFormPage } from '../../page-objects/pages/FillFormPage.js';
+import { PreviewPage } from '../../page-objects/pages/PreviewPage.js';
+
+const DEVICE_ID_REGEX =
+ /^getodk\.org:webforms:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
+const INSTANCE_ID_REGEX =
+ /^uuid:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
+
+test.describe('jr:preload', () => {
+ let formPage: FillFormPage;
+ let start: number;
+
+ const getStartOfDay = (date: number) => {
+ const newDate = new Date(date);
+ newDate.setHours(0, 0, 0, 0);
+ return newDate.valueOf();
+ };
+
+ test.beforeEach(async ({ page }) => {
+ formPage = new FillFormPage(page);
+
+ start = Date.now();
+
+ const previewPage = new PreviewPage(page);
+ await previewPage.goToDevPage();
+ await previewPage.openDevDemoForm('test-javarosa', 'preload.xml', 'jr:preload');
+ });
+
+ test('binds properties', async () => {
+ const end = Date.now();
+
+ const todayDateTime = getStartOfDay(Date.parse(await formPage.note.getValue('today')));
+ expect(todayDateTime).toBeGreaterThanOrEqual(getStartOfDay(start));
+ expect(todayDateTime).toBeLessThanOrEqual(getStartOfDay(end));
+
+ const startDateTime = Date.parse(await formPage.note.getValue('start'));
+ expect(startDateTime).toBeGreaterThanOrEqual(start);
+ expect(startDateTime).toBeLessThanOrEqual(end);
+
+ await formPage.note.expectValueToBeEmpty('end'); // end isn't populated on load
+
+ const deviceID = await formPage.note.getValue('deviceid');
+ expect(deviceID).toMatch(DEVICE_ID_REGEX);
+
+ const instanceID = await formPage.note.getValue('instanceID');
+ expect(instanceID).toMatch(INSTANCE_ID_REGEX);
+
+ // the phonenumber, email, and username are passed in from the demo app
+ await formPage.note.expectValue('phonenumber', '+1235556789');
+ await formPage.note.expectValue('email', 'fake@fake.fake');
+ await formPage.note.expectValue('username', 'nousername');
+
+ await formPage.reload();
+
+ // assert the deviceid hasn't changed - should be loaded from localstorage
+ await formPage.note.expectValue('deviceid', deviceID);
+
+ // assert the instanceID HAS changed
+ const newInstanceID = await formPage.note.getValue('instanceID');
+ expect(newInstanceID).not.toMatch(instanceID);
+ });
+});
diff --git a/packages/web-forms/src/components/OdkWebForm.vue b/packages/web-forms/src/components/OdkWebForm.vue
index 830d3ea39..729635aaa 100644
--- a/packages/web-forms/src/components/OdkWebForm.vue
+++ b/packages/web-forms/src/components/OdkWebForm.vue
@@ -1,5 +1,8 @@
@@ -81,6 +88,7 @@ const handleSubmitChunked = (payload: ChunkedInstancePayload) => {
:fetch-form-attachment="formPreviewState.fetchFormAttachment"
:missing-resource-behavior="formPreviewState.missingResourceBehavior"
:submission-max-size="Infinity"
+ :preload-properties="preloadProperties"
@submit="handleSubmit"
@submit-chunked="handleSubmitChunked"
/>
diff --git a/packages/web-forms/src/lib/init/load-form-state.ts b/packages/web-forms/src/lib/init/load-form-state.ts
index 233ca3593..4c8ba2311 100644
--- a/packages/web-forms/src/lib/init/load-form-state.ts
+++ b/packages/web-forms/src/lib/init/load-form-state.ts
@@ -4,18 +4,22 @@ import type {
FetchResourceResponse,
FormResource,
MissingResourceBehavior,
+ PreloadProperties,
ResolvableFormInstance,
ResolvableFormInstanceInput,
} from '@getodk/xforms-engine';
import { loadForm } from '@getodk/xforms-engine';
import { FormInitializationError } from '../error/FormInitializationError.ts';
+import { ENGINE_FORM_INSTANCE_CONFIG } from './engine-config.ts';
import type {
FormState,
FormStateFailureResult,
FormStateSuccessResult,
InstantiableForm,
} from './form-state.ts';
-import { ENGINE_FORM_INSTANCE_CONFIG } from './engine-config.ts';
+
+const DEVICE_ID_KEY = 'odk-deviceid';
+const DEVICE_ID_PREFIX = 'getodk.org:webforms:';
export interface FormOptions {
readonly fetchFormAttachment: FetchFormAttachment;
@@ -66,6 +70,7 @@ const resolvableFormInstanceInput = (options: EditInstanceOptions): ResolvableFo
interface LoadFormStateOptions {
readonly form: FormOptions;
readonly editInstance?: EditInstanceOptions | null;
+ readonly preloadProperties?: PreloadProperties;
}
const failure = (error: FormInitializationError): FormStateFailureResult => {
@@ -88,6 +93,27 @@ const success = (form: InstantiableForm, instance: AnyFormInstance): FormStateSu
};
};
+const getDeviceId = () => {
+ const id = localStorage.getItem(DEVICE_ID_KEY);
+ if (id) {
+ return id;
+ }
+ const deviceId = DEVICE_ID_PREFIX + crypto.randomUUID();
+ localStorage.setItem(DEVICE_ID_KEY, deviceId);
+ return deviceId;
+};
+
+const getFormInstanceConfig = (options: LoadFormStateOptions) => {
+ const preloadProperties = {
+ ...options.preloadProperties,
+ };
+ preloadProperties.deviceID ??= getDeviceId();
+ return {
+ ...ENGINE_FORM_INSTANCE_CONFIG,
+ preloadProperties,
+ };
+};
+
export const loadFormState = async (
formResource: FormResource,
options: LoadFormStateOptions
@@ -98,9 +124,11 @@ export const loadFormState = async (
return failure(FormInitializationError.fromError(form.error));
}
+ const config = getFormInstanceConfig(options);
+
if (options.editInstance == null) {
try {
- const instance = form.createInstance(ENGINE_FORM_INSTANCE_CONFIG);
+ const instance = form.createInstance(config);
return success(form, instance);
} catch (cause) {
@@ -110,7 +138,7 @@ export const loadFormState = async (
try {
const instanceOptions = resolvableFormInstanceInput(options.editInstance);
- const instance = await form.editInstance(instanceOptions, ENGINE_FORM_INSTANCE_CONFIG);
+ const instance = await form.editInstance(instanceOptions, config);
return success(form, instance);
} catch (cause) {
diff --git a/packages/xforms-engine/src/client/form/FormInstanceConfig.ts b/packages/xforms-engine/src/client/form/FormInstanceConfig.ts
index e0dd4e82d..7b217033f 100644
--- a/packages/xforms-engine/src/client/form/FormInstanceConfig.ts
+++ b/packages/xforms-engine/src/client/form/FormInstanceConfig.ts
@@ -1,6 +1,21 @@
import type { InstanceAttachmentsConfig } from '../attachments/InstanceAttachmentsConfig.ts';
import type { OpaqueReactiveObjectFactory } from '../OpaqueReactiveObjectFactory.ts';
+/**
+ * @see https://getodk.github.io/xforms-spec/#preload-attributes
+ */
+export interface PreloadProperties {
+ /**
+ * The unique identifier for this device. If not provided, then an identifier will be
+ * generated during the first page load and stored in localstorage and reused for
+ * subsequent form loads.
+ */
+ readonly deviceID?: string;
+ readonly email?: string;
+ readonly username?: string;
+ readonly phoneNumber?: string;
+}
+
export interface FormInstanceConfig {
/**
* A client may specify a generic function for constructing stateful objects.
@@ -18,4 +33,6 @@ export interface FormInstanceConfig {
readonly stateFactory?: OpaqueReactiveObjectFactory;
readonly instanceAttachments?: InstanceAttachmentsConfig;
+
+ readonly preloadProperties?: PreloadProperties;
}
diff --git a/packages/xforms-engine/src/entrypoints/FormInstance.ts b/packages/xforms-engine/src/entrypoints/FormInstance.ts
index 8608cab4e..62d9a33ad 100644
--- a/packages/xforms-engine/src/entrypoints/FormInstance.ts
+++ b/packages/xforms-engine/src/entrypoints/FormInstance.ts
@@ -41,6 +41,7 @@ export class FormInstance
const config: InstanceConfig = {
clientStateFactory: instanceConfig.stateFactory ?? identity,
computeAttachmentName: instanceConfig.instanceAttachments?.fileNameFactory ?? (() => null),
+ preloadProperties: instanceConfig.preloadProperties ?? {},
};
const primaryInstanceOptions: PrimaryInstanceOptions = {
...options.instanceOptions,
diff --git a/packages/xforms-engine/src/instance/Attribute.ts b/packages/xforms-engine/src/instance/Attribute.ts
index e98fb6f17..1edfae10c 100644
--- a/packages/xforms-engine/src/instance/Attribute.ts
+++ b/packages/xforms-engine/src/instance/Attribute.ts
@@ -24,6 +24,7 @@ import type { SimpleAtomicState } from '../lib/reactivity/types.ts';
import type { AttributeDefinition } from '../parse/model/AttributeDefinition.ts';
import type { AnyChildNode, AnyNode } from './hierarchy.ts';
import type { AttributeContext } from './internal-api/AttributeContext.ts';
+import type { InstanceConfig } from './internal-api/InstanceConfig.ts';
import type { DecodeInstanceValue } from './internal-api/InstanceValueContext.ts';
import type { ClientReactiveSerializableAttributeNode } from './internal-api/serialization/ClientReactiveSerializableAttributeNode.ts';
import type { PrimaryInstance } from './PrimaryInstance.ts';
@@ -66,6 +67,7 @@ export class Attribute
readonly contextNode: AnyNode;
readonly scope: ReactiveScope;
readonly rootDocument: PrimaryInstance;
+ readonly instanceConfig: InstanceConfig;
readonly root: Root;
@@ -107,6 +109,7 @@ export class Attribute
this.rootDocument = owner.rootDocument;
this.root = owner.root;
+ this.instanceConfig = owner.instanceConfig;
this.getActiveLanguage = owner.getActiveLanguage;
this.validationState = { violations: [] };
@@ -132,7 +135,7 @@ export class Attribute
instanceValue: this.getInstanceValue,
relevant: this.owner.isRelevant,
},
- owner.instanceConfig
+ this.instanceConfig
);
this.state = state;
diff --git a/packages/xforms-engine/src/instance/internal-api/AttributeContext.ts b/packages/xforms-engine/src/instance/internal-api/AttributeContext.ts
index 75d86ea46..b2375ba77 100644
--- a/packages/xforms-engine/src/instance/internal-api/AttributeContext.ts
+++ b/packages/xforms-engine/src/instance/internal-api/AttributeContext.ts
@@ -6,6 +6,7 @@ import type { BindComputationExpression } from '../../parse/expression/BindCompu
import type { AnyBindPreloadDefinition } from '../../parse/model/BindPreloadDefinition.ts';
import type { ModelDefinition } from '../../parse/model/ModelDefinition.ts';
import type { EvaluationContext } from './EvaluationContext.ts';
+import type { InstanceConfig } from './InstanceConfig.ts';
export interface InstanceAttributeContextDocument {
readonly initializationMode: FormInstanceInitializationMode;
@@ -31,6 +32,7 @@ export interface AttributeContext extends EvaluationContext {
readonly rootDocument: InstanceAttributeContextDocument;
readonly definition: InstanceAttributeContextDefinition;
readonly instanceNode: StaticAttribute;
+ readonly instanceConfig: InstanceConfig;
readonly decodeInstanceValue: DecodeInstanceValue;
isReadonly(): boolean;
diff --git a/packages/xforms-engine/src/instance/internal-api/InstanceConfig.ts b/packages/xforms-engine/src/instance/internal-api/InstanceConfig.ts
index baaca86f2..71b9a4fcc 100644
--- a/packages/xforms-engine/src/instance/internal-api/InstanceConfig.ts
+++ b/packages/xforms-engine/src/instance/internal-api/InstanceConfig.ts
@@ -1,5 +1,8 @@
import type { InstanceAttachmentFileNameFactory } from '../../client/attachments/InstanceAttachmentsConfig.ts';
-import type { FormInstanceConfig } from '../../client/form/FormInstanceConfig.ts';
+import type {
+ FormInstanceConfig,
+ PreloadProperties,
+} from '../../client/form/FormInstanceConfig.ts';
import type { OpaqueReactiveObjectFactory } from '../../client/OpaqueReactiveObjectFactory.ts';
export interface InstanceConfig {
@@ -9,4 +12,6 @@ export interface InstanceConfig {
readonly clientStateFactory: OpaqueReactiveObjectFactory;
readonly computeAttachmentName: InstanceAttachmentFileNameFactory;
+
+ readonly preloadProperties: PreloadProperties;
}
diff --git a/packages/xforms-engine/src/instance/internal-api/InstanceValueContext.ts b/packages/xforms-engine/src/instance/internal-api/InstanceValueContext.ts
index a98041f28..30e2e71a8 100644
--- a/packages/xforms-engine/src/instance/internal-api/InstanceValueContext.ts
+++ b/packages/xforms-engine/src/instance/internal-api/InstanceValueContext.ts
@@ -6,6 +6,7 @@ import type { BindComputationExpression } from '../../parse/expression/BindCompu
import type { AnyBindPreloadDefinition } from '../../parse/model/BindPreloadDefinition.ts';
import type { ModelDefinition } from '../../parse/model/ModelDefinition.ts';
import type { EvaluationContext } from './EvaluationContext.ts';
+import type { InstanceConfig } from './InstanceConfig.ts';
export interface InstanceValueContextDocument {
readonly initializationMode: FormInstanceInitializationMode;
@@ -31,6 +32,7 @@ export interface InstanceValueContext extends EvaluationContext {
readonly rootDocument: InstanceValueContextDocument;
readonly definition: InstanceValueContextDefinition;
readonly instanceNode: StaticLeafElement | null;
+ readonly instanceConfig: InstanceConfig;
readonly decodeInstanceValue: DecodeInstanceValue;
isReadonly(): boolean;
diff --git a/packages/xforms-engine/src/instance/internal-api/serialization/ClientReactiveSerializableValueNode.ts b/packages/xforms-engine/src/instance/internal-api/serialization/ClientReactiveSerializableValueNode.ts
index dfca01e29..bccb704fa 100644
--- a/packages/xforms-engine/src/instance/internal-api/serialization/ClientReactiveSerializableValueNode.ts
+++ b/packages/xforms-engine/src/instance/internal-api/serialization/ClientReactiveSerializableValueNode.ts
@@ -1,5 +1,6 @@
import type { InstanceState } from '../../../client/serialization/InstanceState.ts';
import type { QualifiedName } from '../../../lib/names/QualifiedName.ts';
+import type { BindDefinition } from '../../../parse/model/BindDefinition.ts';
import type { Attribute } from '../../Attribute.ts';
import type {
ClientReactiveSerializableChildNode,
@@ -20,6 +21,7 @@ interface ClientReactiveSerializableValueNodeCurrentState {
interface ClientReactiveSerializableValueNodeDefinition {
readonly qualifiedName: QualifiedName;
+ readonly bind: BindDefinition;
}
export interface ClientReactiveSerializableValueNode {
diff --git a/packages/xforms-engine/src/lib/client-reactivity/instance-state/prepareInstancePayload.ts b/packages/xforms-engine/src/lib/client-reactivity/instance-state/prepareInstancePayload.ts
index ec5795bbe..709dbe7f5 100644
--- a/packages/xforms-engine/src/lib/client-reactivity/instance-state/prepareInstancePayload.ts
+++ b/packages/xforms-engine/src/lib/client-reactivity/instance-state/prepareInstancePayload.ts
@@ -208,6 +208,7 @@ export const prepareInstancePayload = (
instanceRoot: ClientReactiveSerializableInstance,
options: PrepareInstancePayloadOptions
): InstancePayload => {
+ instanceRoot.root.parent.model.triggerXformsRevalidateListeners();
const validation = validateInstance(instanceRoot);
const submissionMeta = instanceRoot.definition.submission;
const instanceFile = new InstanceFile(instanceRoot);
diff --git a/packages/xforms-engine/src/lib/reactivity/createInstanceValueState.ts b/packages/xforms-engine/src/lib/reactivity/createInstanceValueState.ts
index 400c62bb4..458080ca2 100644
--- a/packages/xforms-engine/src/lib/reactivity/createInstanceValueState.ts
+++ b/packages/xforms-engine/src/lib/reactivity/createInstanceValueState.ts
@@ -4,7 +4,9 @@ import type { AttributeContext } from '../../instance/internal-api/AttributeCont
import type { InstanceValueContext } from '../../instance/internal-api/InstanceValueContext.ts';
import { ActionComputationExpression } from '../../parse/expression/ActionComputationExpression.ts';
import type { BindComputationExpression } from '../../parse/expression/BindComputationExpression.ts';
-import { ActionDefinition, SET_ACTION_EVENTS } from '../../parse/model/ActionDefinition.ts';
+import { ActionDefinition } from '../../parse/model/ActionDefinition.ts';
+import type { AnyBindPreloadDefinition } from '../../parse/model/BindPreloadDefinition.ts';
+import { XFORM_EVENT } from '../../parse/model/Event.ts';
import { createComputedExpression } from './createComputedExpression.ts';
import type { SimpleAtomicState, SimpleAtomicStateSetter } from './types.ts';
@@ -22,8 +24,6 @@ const isAddingRepeatChild = (context: ValueContext) => {
/**
* Special case, does not correspond to any event.
- *
- * @see {@link shouldPreloadUID}
*/
const isEditInitialLoad = (context: ValueContext) => {
return context.rootDocument.initializationMode === 'edit';
@@ -94,39 +94,46 @@ const guardDownstreamReadonlyWrites = (
return [getValue, setValue];
};
-/**
- * Per {@link https://getodk.github.io/xforms-spec/#preload-attributes:~:text=concatenation%20of%20%E2%80%98uuid%3A%E2%80%99%20and%20uuid()}
- */
-const PRELOAD_UID_EXPRESSION = 'concat("uuid:", uuid())';
-
/**
* @todo It feels increasingly awkward to keep piling up preload stuff here, but it won't stay that way for long. In the meantime, this seems like the best way to express the cases where `preload="uid"` should be effective, i.e.:
*
* - When an instance is first loaded ({@link isInstanceFirstLoad})
* - When an instance is initially loaded for editing ({@link isEditInitialLoad})
*/
-const shouldPreloadUID = (context: ValueContext) => {
+const isLoading = (context: ValueContext) => {
return isInstanceFirstLoad(context) || isEditInitialLoad(context);
};
-/**
- * @todo This is a temporary one-off, until we support the full range of
- * {@link https://getodk.github.io/xforms-spec/#preload-attributes | preloads}.
- */
-const setPreloadUIDValue = (context: ValueContext, valueState: RelevantValueState): void => {
- const { preload } = context.definition.bind;
+const postloadValue = (
+ context: ValueContext,
+ setValue: SimpleAtomicStateSetter,
+ preload: AnyBindPreloadDefinition
+) => {
+ context.definition.model.registerXformsRevalidateListener(() => {
+ const value = preload.getValue(context);
+ if (value) {
+ setValue(value);
+ }
+ });
+};
- if (preload?.type !== 'uid' || !shouldPreloadUID(context)) {
+const preloadValue = (context: ValueContext, setValue: SimpleAtomicStateSetter): void => {
+ const { preload } = context.definition.bind;
+ if (!preload) {
return;
}
- const preloadUIDValue = context.evaluator.evaluateString(PRELOAD_UID_EXPRESSION, {
- contextNode: context.contextNode,
- });
-
- const [, setValue] = valueState;
+ if (preload.event === XFORM_EVENT.xformsRevalidate) {
+ postloadValue(context, setValue, preload);
+ return;
+ }
- setValue(preloadUIDValue);
+ if (isLoading(context)) {
+ const value = preload.getValue(context);
+ if (value) {
+ setValue(value);
+ }
+ }
};
const referencesCurrentNode = (context: ValueContext, ref: string): boolean => {
@@ -168,7 +175,7 @@ const bindToRepeatInstance = (
* computations to the provided value setter, on initialization and any
* subsequent reactive update.
*
- * @see {@link setPreloadUIDValue} for important details about spec ordering of
+ * @see {@link preloadValue} for important details about spec ordering of
* events and computations.
*/
const createCalculation = (
@@ -220,22 +227,22 @@ const registerAction = (
setValue: SimpleAtomicStateSetter,
action: ActionDefinition
) => {
- if (action.events.includes(SET_ACTION_EVENTS.odkInstanceFirstLoad)) {
+ if (action.events.includes(XFORM_EVENT.odkInstanceFirstLoad)) {
if (isInstanceFirstLoad(context)) {
createCalculation(context, setValue, action.computation);
}
}
- if (action.events.includes(SET_ACTION_EVENTS.odkInstanceLoad)) {
+ if (action.events.includes(XFORM_EVENT.odkInstanceLoad)) {
if (!isAddingRepeatChild(context)) {
createCalculation(context, setValue, action.computation);
}
}
- if (action.events.includes(SET_ACTION_EVENTS.odkNewRepeat)) {
+ if (action.events.includes(XFORM_EVENT.odkNewRepeat)) {
if (isAddingRepeatChild(context)) {
createCalculation(context, setValue, action.computation);
}
}
- if (action.events.includes(SET_ACTION_EVENTS.xformsValueChanged)) {
+ if (action.events.includes(XFORM_EVENT.xformsValueChanged)) {
createValueChangedCalculation(context, setValue, action);
}
};
@@ -259,13 +266,10 @@ export const createInstanceValueState = (context: ValueContext): InstanceValueSt
const baseValueState = createSignal(initialValue);
const relevantValueState = createRelevantValueState(context, baseValueState);
- /**
- * @see {@link setPreloadUIDValue} for important details about spec ordering of events and computations.
- */
- setPreloadUIDValue(context, relevantValueState);
-
const [, setValue] = relevantValueState;
+ preloadValue(context, setValue);
+
const { calculate } = context.definition.bind;
if (calculate != null) {
createCalculation(context, setValue, calculate);
diff --git a/packages/xforms-engine/src/parse/model/ActionDefinition.ts b/packages/xforms-engine/src/parse/model/ActionDefinition.ts
index 49afb973a..75dde28d2 100644
--- a/packages/xforms-engine/src/parse/model/ActionDefinition.ts
+++ b/packages/xforms-engine/src/parse/model/ActionDefinition.ts
@@ -1,17 +1,8 @@
import { isTextNode } from '@getodk/common/lib/dom/predicates.ts';
import { ActionComputationExpression } from '../expression/ActionComputationExpression.ts';
+import { isKnownEvent, type XFormEvent } from './Event.ts';
import type { ModelDefinition } from './ModelDefinition.ts';
-export const SET_ACTION_EVENTS = {
- odkInstanceLoad: 'odk-instance-load',
- odkInstanceFirstLoad: 'odk-instance-first-load',
- odkNewRepeat: 'odk-new-repeat',
- xformsValueChanged: 'xforms-value-changed',
-} as const;
-type SetActionEvent = (typeof SET_ACTION_EVENTS)[keyof typeof SET_ACTION_EVENTS];
-const isKnownEvent = (event: SetActionEvent): event is SetActionEvent =>
- Object.values(SET_ACTION_EVENTS).includes(event);
-
export class ActionDefinition {
static getRef(model: ModelDefinition, setValueElement: Element): string | null {
if (setValueElement.hasAttribute('ref')) {
@@ -39,19 +30,19 @@ export class ActionDefinition {
return "''";
}
- static getEvents(element: Element): SetActionEvent[] {
+ static getEvents(element: Element): XFormEvent[] {
const events = element.getAttribute('event')?.split(' ') ?? [];
- const unknownEvents = events.filter((event) => !isKnownEvent(event as SetActionEvent));
+ const unknownEvents = events.filter((event) => !isKnownEvent(event as XFormEvent));
if (unknownEvents.length) {
throw new Error(
`An action was registered for unsupported events: ${unknownEvents.join(', ')}`
);
}
- return events as SetActionEvent[];
+ return events as XFormEvent[];
}
readonly ref: string;
- readonly events: SetActionEvent[];
+ readonly events: XFormEvent[];
readonly computation: ActionComputationExpression<'string'>;
readonly source: string | undefined;
diff --git a/packages/xforms-engine/src/parse/model/BindDefinition.ts b/packages/xforms-engine/src/parse/model/BindDefinition.ts
index 65d53f19a..a71b7e5a7 100644
--- a/packages/xforms-engine/src/parse/model/BindDefinition.ts
+++ b/packages/xforms-engine/src/parse/model/BindDefinition.ts
@@ -38,9 +38,7 @@ export class BindDefinition extends DependencyCon
// https://github.com/getodk/collect/issues/3758 mentions deprecation.
readonly saveIncomplete: BindComputationExpression<'saveIncomplete'>;
- // TODO: these are deferred until prioritized
- // readonly preloadParams: string | null;
- // readonly 'max-pixels': string | null;
+ // TODO: deferred until prioritized: readonly 'max-pixels': string | null;
protected _parentBind: BindDefinition | null | undefined;
@@ -94,7 +92,6 @@ export class BindDefinition extends DependencyCon
this.constraintMsg = MessageDefinition.from(this, 'constraintMsg');
this.requiredMsg = MessageDefinition.from(this, 'requiredMsg');
- // this.preloadParams = BindComputation.forExpression(this, 'preloadParams');
// this['max-pixels'] = BindComputation.forExpression(this, 'max-pixels');
}
diff --git a/packages/xforms-engine/src/parse/model/BindPreloadDefinition.ts b/packages/xforms-engine/src/parse/model/BindPreloadDefinition.ts
index 585e68a74..d790da5cd 100644
--- a/packages/xforms-engine/src/parse/model/BindPreloadDefinition.ts
+++ b/packages/xforms-engine/src/parse/model/BindPreloadDefinition.ts
@@ -1,6 +1,14 @@
import { JAVAROSA_NAMESPACE_URI } from '@getodk/common/constants/xmlns.ts';
import type { PartiallyKnownString } from '@getodk/common/types/string/PartiallyKnownString.ts';
+import type { AttributeContext } from '../../instance/internal-api/AttributeContext.ts';
+import type { InstanceValueContext } from '../../instance/internal-api/InstanceValueContext.ts';
import type { BindElement } from './BindElement.ts';
+import { XFORM_EVENT, type XFormEvent } from './Event.ts';
+
+/**
+ * Per {@link https://getodk.github.io/xforms-spec/#preload-attributes:~:text=concatenation%20of%20%E2%80%98uuid%3A%E2%80%99%20and%20uuid()}
+ */
+const PRELOAD_UID_EXPRESSION = 'concat("uuid:", uuid())';
type PartiallyKnownPreloadParameter =
// eslint-disable-next-line @typescript-eslint/sort-type-constituents
@@ -61,15 +69,6 @@ const getPreloadInput = (bindElement: BindElement): AnyPreloadInput | null => {
*
* - {@link type}, a `jr:preload`
* - {@link parameter}, an associated `jr:preloadParams` value
- *
- * @todo It would probably make sense for the _definition_ to also convey:
- *
- * 1. Which {@link https://getodk.github.io/xforms-spec/#events | event} the
- * preload is semantically associated with (noting that the spec may be a tad
- * overzealous about this association).
- *
- * 2. The constant XPath expression (or other computation?) expressed by the
- * combined {@link type} and {@link parameter}.
*/
export class BindPreloadDefinition implements PreloadInput {
static from(bindElement: BindElement): AnyBindPreloadDefinition | null {
@@ -84,10 +83,43 @@ export class BindPreloadDefinition implements PreloadI
readonly type: Type;
readonly parameter: PreloadParameter;
+ readonly event: XFormEvent;
+
+ getValue(context: AttributeContext | InstanceValueContext): string | undefined {
+ if (this.type === 'uid') {
+ return context.evaluator.evaluateString(PRELOAD_UID_EXPRESSION);
+ }
+ if (this.type === 'timestamp') {
+ return context.evaluator.evaluateString('now()');
+ }
+ if (this.type === 'date') {
+ return context.evaluator.evaluateString('today()');
+ }
+ if (this.type === 'property') {
+ const properties = context.instanceConfig.preloadProperties;
+ if (this.parameter === 'deviceid') {
+ return properties.deviceID;
+ }
+ if (this.parameter === 'email') {
+ return properties.email;
+ }
+ if (this.parameter === 'phonenumber') {
+ return properties.phoneNumber;
+ }
+ if (this.parameter === 'username') {
+ return properties.username;
+ }
+ }
+ return;
+ }
private constructor(input: PreloadInput) {
this.type = input.type;
this.parameter = input.parameter;
+ this.event =
+ this.type === 'timestamp' && this.parameter === 'end'
+ ? XFORM_EVENT.xformsRevalidate
+ : XFORM_EVENT.odkInstanceFirstLoad;
}
}
diff --git a/packages/xforms-engine/src/parse/model/Event.ts b/packages/xforms-engine/src/parse/model/Event.ts
new file mode 100644
index 000000000..3945741c0
--- /dev/null
+++ b/packages/xforms-engine/src/parse/model/Event.ts
@@ -0,0 +1,12 @@
+export const XFORM_EVENT = {
+ odkInstanceLoad: 'odk-instance-load',
+ odkInstanceFirstLoad: 'odk-instance-first-load',
+ odkNewRepeat: 'odk-new-repeat',
+ xformsRevalidate: 'xforms-revalidate',
+ xformsValueChanged: 'xforms-value-changed',
+} as const;
+
+export type XFormEvent = (typeof XFORM_EVENT)[keyof typeof XFORM_EVENT];
+
+export const isKnownEvent = (event: XFormEvent): event is XFormEvent =>
+ Object.values(XFORM_EVENT).includes(event);
diff --git a/packages/xforms-engine/src/parse/model/ModelActionMap.ts b/packages/xforms-engine/src/parse/model/ModelActionMap.ts
index 0dee61cae..aeec552ee 100644
--- a/packages/xforms-engine/src/parse/model/ModelActionMap.ts
+++ b/packages/xforms-engine/src/parse/model/ModelActionMap.ts
@@ -1,4 +1,5 @@
-import { ActionDefinition, SET_ACTION_EVENTS } from './ActionDefinition.ts';
+import { ActionDefinition } from './ActionDefinition.ts';
+import { XFORM_EVENT } from './Event.ts';
import type { ModelDefinition } from './ModelDefinition.ts';
const REPEAT_REGEX = /(\[[^\]]*\])/gm;
@@ -16,7 +17,7 @@ export class ModelActionMap extends Map {
super(
model.form.xformDOM.setValues.map((setValueElement) => {
const action = new ActionDefinition(model, setValueElement);
- if (action.events.includes(SET_ACTION_EVENTS.odkNewRepeat)) {
+ if (action.events.includes(XFORM_EVENT.odkNewRepeat)) {
throw new Error('Model contains "setvalue" element with "odk-new-repeat" event');
}
const key = ModelActionMap.getKey(action.ref);
diff --git a/packages/xforms-engine/src/parse/model/ModelDefinition.ts b/packages/xforms-engine/src/parse/model/ModelDefinition.ts
index a3f6b4e01..825611403 100644
--- a/packages/xforms-engine/src/parse/model/ModelDefinition.ts
+++ b/packages/xforms-engine/src/parse/model/ModelDefinition.ts
@@ -14,6 +14,8 @@ import { nodeDefinitionMap } from './nodeDefinitionMap.ts';
import { RootDefinition } from './RootDefinition.ts';
import { SubmissionDefinition } from './SubmissionDefinition.ts';
+type XformsRevalidateListener = () => void;
+
export class ModelDefinition {
readonly binds: ModelBindMap;
readonly actions: ModelActionMap;
@@ -22,6 +24,7 @@ export class ModelDefinition {
readonly instance: StaticDocument;
readonly itextTranslations: ItextTranslationsDefinition;
readonly itextChunks: Map;
+ readonly listeners: XformsRevalidateListener[];
constructor(readonly form: XFormDefinition) {
const submission = new SubmissionDefinition(form.xformDOM);
@@ -35,6 +38,7 @@ export class ModelDefinition {
this.nodes = nodeDefinitionMap(this.root);
this.itextTranslations = ItextTranslationsDefinition.from(form.xformDOM);
this.itextChunks = generateItextChunks(form.xformDOM.itextTranslationElements);
+ this.listeners = [];
}
getNodeDefinition(nodeset: string): AnyNodeDefinition {
@@ -57,10 +61,12 @@ export class ModelDefinition {
return definition;
}
- toJSON() {
- const { form, ...rest } = this;
+ registerXformsRevalidateListener(listener: XformsRevalidateListener) {
+ this.listeners.push(listener);
+ }
- return rest;
+ triggerXformsRevalidateListeners() {
+ this.listeners.forEach((listener: XformsRevalidateListener) => listener());
}
getTranslationChunks(
@@ -70,4 +76,10 @@ export class ModelDefinition {
const languageMap = this.itextChunks.get(activeLanguage.language);
return languageMap?.get(itextId) ?? [];
}
+
+ toJSON() {
+ const { form, ...rest } = this;
+
+ return rest;
+ }
}
diff --git a/packages/xforms-engine/test/instance/PrimaryInstance.test.ts b/packages/xforms-engine/test/instance/PrimaryInstance.test.ts
index d36ad2c19..79b3235d4 100644
--- a/packages/xforms-engine/test/instance/PrimaryInstance.test.ts
+++ b/packages/xforms-engine/test/instance/PrimaryInstance.test.ts
@@ -86,6 +86,7 @@ describe('PrimaryInstance engine representation of instance state', () => {
config: {
clientStateFactory,
computeAttachmentName: () => null,
+ preloadProperties: {},
},
});
});