From b0903144a5ef400c399c235bbc470d4e7dfd22ac Mon Sep 17 00:00:00 2001 From: Gareth Bowen Date: Mon, 24 Nov 2025 15:16:36 +1300 Subject: [PATCH 01/16] first steps of jr preload --- .../xform-dsl/BindBuilderXFormsElement.ts | 7 +- packages/scenario/test/jr-preload.test.ts | 149 ++++++++++++++---- .../ClientReactiveSerializableValueNode.ts | 2 + .../createValueNodeInstanceState.ts | 15 +- .../reactivity/createInstanceValueState.ts | 50 +++--- .../src/parse/model/BindDefinition.ts | 5 +- 6 files changed, 169 insertions(+), 59 deletions(-) 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/test/jr-preload.test.ts b/packages/scenario/test/jr-preload.test.ts index 51e28128b..2fb8ce03f 100644 --- a/packages/scenario/test/jr-preload.test.ts +++ b/packages/scenario/test/jr-preload.test.ts @@ -9,45 +9,126 @@ 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'; 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')) - ) - ); - - expect(scenario.answerOf('/data/element')).toStartWith('uuid:'); + 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:'); + }); }); - // 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 actual = Temporal.Instant.from( + scenario.answerOf('/data/element').toString() + ).epochNanoseconds; + + expect(actual).toBeGreaterThanOrEqual(start); + expect(actual).toBeLessThanOrEqual(end); + }); + + 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); + 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; + 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 actual = Temporal.Instant.from(timestampElement[1]).epochNanoseconds; + expect(actual).toBeGreaterThanOrEqual(start); + expect(actual).toBeLessThanOrEqual(end); + }); }); }); 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/createValueNodeInstanceState.ts b/packages/xforms-engine/src/lib/client-reactivity/instance-state/createValueNodeInstanceState.ts index 499d1026b..1acf378cf 100644 --- a/packages/xforms-engine/src/lib/client-reactivity/instance-state/createValueNodeInstanceState.ts +++ b/packages/xforms-engine/src/lib/client-reactivity/instance-state/createValueNodeInstanceState.ts @@ -1,7 +1,19 @@ +import { Temporal } from 'temporal-polyfill'; import type { InstanceState } from '../../../client/serialization/InstanceState.ts'; import type { ClientReactiveSerializableValueNode } from '../../../instance/internal-api/serialization/ClientReactiveSerializableValueNode.ts'; import { escapeXMLText, serializeLeafElementXML } from '../../xml-serialization.ts'; +const getValue = (node: ClientReactiveSerializableValueNode): string => { + const preload = node.definition.bind.preload; + if (preload) { + if (preload.type === 'timestamp' && preload.parameter === 'end') { + return Temporal.Now.instant().toString(); + } + } + + return escapeXMLText(node.currentState.instanceValue); +}; + export const createValueNodeInstanceState = ( node: ClientReactiveSerializableValueNode ): InstanceState => { @@ -13,7 +25,8 @@ export const createValueNodeInstanceState = ( return ''; } - const xmlValue = escapeXMLText(node.currentState.instanceValue); + const value = getValue(node); + const xmlValue = escapeXMLText(value); const attributes = node.currentState.attributes; return serializeLeafElementXML(qualifiedName, xmlValue, attributes); diff --git a/packages/xforms-engine/src/lib/reactivity/createInstanceValueState.ts b/packages/xforms-engine/src/lib/reactivity/createInstanceValueState.ts index 400c62bb4..92ca8be14 100644 --- a/packages/xforms-engine/src/lib/reactivity/createInstanceValueState.ts +++ b/packages/xforms-engine/src/lib/reactivity/createInstanceValueState.ts @@ -1,10 +1,12 @@ import type { Signal } from 'solid-js'; import { createComputed, createMemo, createSignal, untrack } from 'solid-js'; +import { Temporal } from 'temporal-polyfill'; import type { AttributeContext } from '../../instance/internal-api/AttributeContext.ts'; 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 type { AnyBindPreloadDefinition } from '../../parse/model/BindPreloadDefinition.ts'; import { createComputedExpression } from './createComputedExpression.ts'; import type { SimpleAtomicState, SimpleAtomicStateSetter } from './types.ts'; @@ -105,28 +107,43 @@ const PRELOAD_UID_EXPRESSION = 'concat("uuid:", uuid())'; * - When an instance is first loaded ({@link isInstanceFirstLoad}) * - When an instance is initially loaded for editing ({@link isEditInitialLoad}) */ +// TODO rename this, something like: isLoading? const shouldPreloadUID = (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 getPreloadValue = ( + context: ValueContext, + preload: AnyBindPreloadDefinition +): string | undefined => { + if (preload.type === 'uid') { + return context.evaluator.evaluateString(PRELOAD_UID_EXPRESSION, { + contextNode: context.contextNode, + }); + } + if (preload.type === 'timestamp' && preload.parameter === 'start') { + return Temporal.Now.instant().toString(); + } + if (preload.type === 'date' && preload.parameter === 'today') { + return Temporal.Now.plainDateISO().toString(); + } +}; + +// TODO rename because it's now doing every preload +const setPreloadUIDValue = ( + context: ValueContext, + setValue: SimpleAtomicStateSetter +): void => { const { preload } = context.definition.bind; - if (preload?.type !== 'uid' || !shouldPreloadUID(context)) { + if (!preload || !shouldPreloadUID(context)) { return; } - const preloadUIDValue = context.evaluator.evaluateString(PRELOAD_UID_EXPRESSION, { - contextNode: context.contextNode, - }); - - const [, setValue] = valueState; - - setValue(preloadUIDValue); + const value = getPreloadValue(context, preload); + if (value) { + setValue(value); + } }; const referencesCurrentNode = (context: ValueContext, ref: string): boolean => { @@ -259,13 +276,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; + setPreloadUIDValue(context, setValue); + const { calculate } = context.definition.bind; if (calculate != null) { createCalculation(context, setValue, calculate); 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'); } From b0a67973e5756c9df475f03acb734ad34c0e439b Mon Sep 17 00:00:00 2001 From: Gareth Bowen Date: Mon, 24 Nov 2025 17:49:32 +1300 Subject: [PATCH 02/16] first stab at passing property preloads --- packages/scenario/src/client/init.ts | 3 ++ packages/scenario/src/jr/Scenario.ts | 1 + packages/scenario/test/jr-preload.test.ts | 51 +++++++++++++++++++ .../web-forms/src/components/OdkWebForm.vue | 11 ++-- .../web-forms/src/lib/init/load-form-state.ts | 20 ++++++-- .../src/client/form/FormInstanceConfig.ts | 9 ++++ .../src/entrypoints/FormInstance.ts | 1 + .../xforms-engine/src/instance/Attribute.ts | 5 +- .../instance/internal-api/AttributeContext.ts | 2 + .../instance/internal-api/InstanceConfig.ts | 7 ++- .../internal-api/InstanceValueContext.ts | 2 + .../createValueNodeInstanceState.ts | 1 + .../reactivity/createInstanceValueState.ts | 16 ++++++ 13 files changed, 120 insertions(+), 9 deletions(-) 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 483c13c03..fde204b10 100644 --- a/packages/scenario/src/jr/Scenario.ts +++ b/packages/scenario/src/jr/Scenario.ts @@ -175,6 +175,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 2fb8ce03f..ec7388efc 100644 --- a/packages/scenario/test/jr-preload.test.ts +++ b/packages/scenario/test/jr-preload.test.ts @@ -131,4 +131,55 @@ describe('`jr:preload`', () => { expect(actual).toBeLessThanOrEqual(end); }); }); + + // TODO need an e2e test for this too because need to test integration + 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('phone number') + ) + ), + body() + ), + { + preloadProperties: { + deviceid: deviceId, + email: email, + username: username, + phonenumber: phoneNumber, + }, + } + ); + + 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/src/components/OdkWebForm.vue b/packages/web-forms/src/components/OdkWebForm.vue index 830d3ea39..209f439a9 100644 --- a/packages/web-forms/src/components/OdkWebForm.vue +++ b/packages/web-forms/src/components/OdkWebForm.vue @@ -1,5 +1,8 @@