diff --git a/.changeset/ninety-pets-post.md b/.changeset/ninety-pets-post.md new file mode 100644 index 000000000..80752b900 --- /dev/null +++ b/.changeset/ninety-pets-post.md @@ -0,0 +1,7 @@ +--- +'@getodk/xforms-engine': patch +'@getodk/scenario': patch +'@getodk/web-forms': patch +--- + +Set attribute value to instance value when editing submission diff --git a/packages/scenario/src/client/init.ts b/packages/scenario/src/client/init.ts index 0f32784cd..127987243 100644 --- a/packages/scenario/src/client/init.ts +++ b/packages/scenario/src/client/init.ts @@ -1,5 +1,6 @@ import type { JRResourceService } from '@getodk/common/jr-resources/JRResourceService.ts'; import type { + EditFormInstanceInput, FormResource, GeolocationProvider, InstanceAttachmentsConfig, @@ -11,7 +12,7 @@ import type { PreloadProperties, RootNode, } from '@getodk/xforms-engine'; -import { createInstance } from '@getodk/xforms-engine'; +import { createInstance, editInstance } from '@getodk/xforms-engine'; import type { Owner } from 'solid-js'; import { createRoot } from 'solid-js'; import { getAssertedOwner, runInSolidScope } from './solid-helpers.ts'; @@ -31,6 +32,7 @@ export interface TestFormOptions { readonly instanceAttachments: InstanceAttachmentsConfig; readonly preloadProperties: PreloadProperties; readonly geolocationProvider: GeolocationProvider; + readonly editInstance: EditFormInstanceInput | null; } const defaultConfig = { @@ -57,7 +59,7 @@ export const initializeTestForm = async ( const owner = getAssertedOwner(); const { formResult: form, root: instanceRoot } = await runInSolidScope(owner, async () => { - return createInstance(formResource, { + const initOptions = { form: { ...defaultConfig, fetchFormAttachment: options.resourceService.handleRequest, @@ -69,7 +71,11 @@ export const initializeTestForm = async ( preloadProperties: options.preloadProperties, geolocationProvider: options.geolocationProvider, }, - }); + }; + if (options.editInstance) { + return editInstance(formResource, options.editInstance, initOptions); + } + return createInstance(formResource, initOptions); }); return { diff --git a/packages/scenario/src/jr/Scenario.ts b/packages/scenario/src/jr/Scenario.ts index 64177aedd..47ec338d6 100644 --- a/packages/scenario/src/jr/Scenario.ts +++ b/packages/scenario/src/jr/Scenario.ts @@ -179,6 +179,7 @@ export class Scenario { geolocationProvider: overrideOptions?.geolocationProvider ?? { getLocation: () => Promise.resolve(''), }, + editInstance: overrideOptions?.editInstance ?? null, }; } diff --git a/packages/scenario/test/bind-attributes.test.ts b/packages/scenario/test/bind-attributes.test.ts index 8e1c838e3..e5e241c25 100644 --- a/packages/scenario/test/bind-attributes.test.ts +++ b/packages/scenario/test/bind-attributes.test.ts @@ -11,12 +11,15 @@ import { model, repeat, select1, + setvalue, t, title, } from '@getodk/common/test/fixtures/xform-dsl/index.ts'; +import { constants, type InstanceData } from '@getodk/xforms-engine'; import { beforeEach, describe, expect, it } from 'vitest'; import { stringAnswer } from '../src/answer/ExpectedStringAnswer.ts'; import { Scenario } from '../src/jr/Scenario.ts'; +const { INSTANCE_FILE_NAME, INSTANCE_FILE_TYPE } = constants; const IGNORED_INSTANCE_ID = 'ignored for purposes of functionality under test'; @@ -138,6 +141,52 @@ describe('Bind attributes', () => { }); }); + it('binds attributes from instance data when editing', async () => { + const instanceXML = ` + cat + sammy + + uuid:c0b9c932-e78b-474b-8568-48980113a7ac + + uuid:da0bb609-4293-48bd-ba64-136ffa1c43d3 + +`; + const instanceFile = new File([instanceXML], INSTANCE_FILE_NAME, { + type: INSTANCE_FILE_TYPE, + }); + const instanceData = new FormData(); + instanceData.set(INSTANCE_FILE_NAME, instanceFile); + const form = html( + head( + title('Neighborhood pet: add'), + model( + mainInstance( + t( + 'data id="pets_add"', + t('species'), + t('name'), + t('meta', t('entity create="1" dataset="pets" id=""', t('label'))) + ) + ), + bind('/data/name').type('string'), + bind('/data/meta/entity/@create').calculate('1').type('string'), + bind('/data/meta/entity/label').calculate('/data/name').type('string'), + setvalue('odk-instance-first-load', '/data/meta/entity/@id', 'uuid()') + ) + ), + body(input('/data/species'), input('/data/name')) + ); + const scenario = await Scenario.init('upgrade form', form, { + editInstance: { + inputType: 'FORM_INSTANCE_INPUT_RESOLVED', + data: [instanceData as InstanceData], + }, + }); + expect(scenario.attributeOf('/data/meta/entity', 'id').getValue()).to.equal( + '0ed0b751-0d7d-4342-a818-67910a01df9a' + ); + }); + describe('can evaluate the attribute value', () => { let scenario: Scenario; diff --git a/packages/xforms-engine/src/instance/attachments/buildAttributes.ts b/packages/xforms-engine/src/instance/attachments/buildAttributes.ts index d62cb4773..e4b353590 100644 --- a/packages/xforms-engine/src/instance/attachments/buildAttributes.ts +++ b/packages/xforms-engine/src/instance/attachments/buildAttributes.ts @@ -1,3 +1,6 @@ +import type { StaticAttribute } from '../../integration/xpath/static-dom/StaticAttribute.ts'; +import type { StaticDocument } from '../../integration/xpath/static-dom/StaticDocument.ts'; +import type { StaticElement } from '../../integration/xpath/static-dom/StaticElement.ts'; import { Attribute } from '../Attribute'; import type { AnyNode } from '../hierarchy.ts'; import type { InputControl } from '../InputControl.ts'; @@ -5,6 +8,19 @@ import type { ModelValue } from '../ModelValue.ts'; import type { Note } from '../Note.ts'; import type { RangeControl } from '../RangeControl.ts'; +function buildInstanceAttributeMap( + instanceNode: StaticAttribute | StaticDocument | StaticElement | null +): Map { + const map = new Map(); + if (!instanceNode) { + return map; + } + for (const attribute of instanceNode.attributes) { + map.set(attribute.qualifiedName.getPrefixedName(), attribute); + } + return map; +} + export function buildAttributes( // eslint-disable-next-line @typescript-eslint/no-explicit-any owner: AnyNode | InputControl | ModelValue | Note | RangeControl @@ -13,7 +29,11 @@ export function buildAttributes( if (!attributes) { return []; } + const instanceAttributes = buildInstanceAttributeMap(owner.instanceNode); return Array.from(attributes.values()).map((attributeDefinition) => { - return new Attribute(owner, attributeDefinition, attributeDefinition.template); + const instanceNode = + instanceAttributes.get(attributeDefinition.qualifiedName.getPrefixedName()) ?? + attributeDefinition.template; + return new Attribute(owner, attributeDefinition, instanceNode); }); } diff --git a/packages/xforms-engine/src/integration/xpath/static-dom/StaticDocument.ts b/packages/xforms-engine/src/integration/xpath/static-dom/StaticDocument.ts index 90dc322c9..d11002b91 100644 --- a/packages/xforms-engine/src/integration/xpath/static-dom/StaticDocument.ts +++ b/packages/xforms-engine/src/integration/xpath/static-dom/StaticDocument.ts @@ -1,4 +1,5 @@ import type { XFormsXPathDocument } from '../adapter/XFormsXPathNode.ts'; +import type { StaticAttribute } from './StaticAttribute.ts'; import type { StaticElementOptions } from './StaticElement.ts'; import { StaticElement } from './StaticElement.ts'; import { StaticParentNode } from './StaticParentNode.ts'; @@ -15,6 +16,7 @@ export class StaticDocument extends StaticParentNode<'document'> implements XFor readonly nodeset: string; readonly children: readonly [root: StaticElement]; readonly childElements: readonly [root: StaticElement]; + readonly attributes: readonly StaticAttribute[] = []; constructor(options: StaticDocumentOptions) { super('document');