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 @@