From 682e9c24c460521936b385921af4625bce3ecfde Mon Sep 17 00:00:00 2001 From: eyelidlessness Date: Fri, 13 Sep 2024 06:59:18 -0700 Subject: [PATCH 01/18] xforms-engine: reorganize a couple of parse test suites missed in #213 --- .../test/{ => parse}/model/ModelBindMap.test.ts | 6 +++--- .../test/{ => parse}/model/ModelDefinition.test.ts | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) rename packages/xforms-engine/test/{ => parse}/model/ModelBindMap.test.ts (85%) rename packages/xforms-engine/test/{ => parse}/model/ModelDefinition.test.ts (96%) diff --git a/packages/xforms-engine/test/model/ModelBindMap.test.ts b/packages/xforms-engine/test/parse/model/ModelBindMap.test.ts similarity index 85% rename from packages/xforms-engine/test/model/ModelBindMap.test.ts rename to packages/xforms-engine/test/parse/model/ModelBindMap.test.ts index a7b9effea..66e7b0541 100644 --- a/packages/xforms-engine/test/model/ModelBindMap.test.ts +++ b/packages/xforms-engine/test/parse/model/ModelBindMap.test.ts @@ -9,9 +9,9 @@ import { title, } from '@getodk/common/test/fixtures/xform-dsl'; import { beforeEach, describe, expect, it } from 'vitest'; -import { BindDefinition } from '../../src/parse/model/BindDefinition.ts'; -import type { ModelBindMap } from '../../src/parse/model/ModelBindMap.ts'; -import { XFormDefinition } from '../../src/parse/XFormDefinition.ts'; +import { BindDefinition } from '../../../src/parse/model/BindDefinition.ts'; +import type { ModelBindMap } from '../../../src/parse/model/ModelBindMap.ts'; +import { XFormDefinition } from '../../../src/parse/XFormDefinition.ts'; describe('ModelBindMap', () => { let binds: ModelBindMap; diff --git a/packages/xforms-engine/test/model/ModelDefinition.test.ts b/packages/xforms-engine/test/parse/model/ModelDefinition.test.ts similarity index 96% rename from packages/xforms-engine/test/model/ModelDefinition.test.ts rename to packages/xforms-engine/test/parse/model/ModelDefinition.test.ts index d3d1ac4e6..e3945323f 100644 --- a/packages/xforms-engine/test/model/ModelDefinition.test.ts +++ b/packages/xforms-engine/test/parse/model/ModelDefinition.test.ts @@ -15,11 +15,11 @@ import { title, } from '@getodk/common/test/fixtures/xform-dsl/index.ts'; import { beforeEach, describe, expect, it } from 'vitest'; -import { BindDefinition } from '../../src/parse/model/BindDefinition.ts'; -import type { LeafNodeDefinition } from '../../src/parse/model/LeafNodeDefinition.ts'; -import { ModelDefinition } from '../../src/parse/model/ModelDefinition.ts'; -import type { RepeatRangeDefinition } from '../../src/parse/model/RepeatRangeDefinition.ts'; -import { XFormDefinition } from '../../src/parse/XFormDefinition.ts'; +import { BindDefinition } from '../../../src/parse/model/BindDefinition.ts'; +import type { LeafNodeDefinition } from '../../../src/parse/model/LeafNodeDefinition.ts'; +import { ModelDefinition } from '../../../src/parse/model/ModelDefinition.ts'; +import type { RepeatRangeDefinition } from '../../../src/parse/model/RepeatRangeDefinition.ts'; +import { XFormDefinition } from '../../../src/parse/XFormDefinition.ts'; describe('ModelDefinition', () => { let modelDefinition: ModelDefinition; From cd642577602fb5651fbcfc762145ae2fca25571e Mon Sep 17 00:00:00 2001 From: eyelidlessness Date: Fri, 30 Aug 2024 11:39:34 -0700 Subject: [PATCH 02/18] Initial engine/client interface: submission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is essentially the design originally proposed in https://github.com/getodk/web-forms/issues/188, with the following modifications: - Add a `SubmissionResult.status` and corresponding `SubmissionResult` variant, to convey submission data with attachments exceeding a client-specified `maxSize` - Add corresponding `MaxSizeViolation` interface for that violation scenario… - … including a todo discussing potential benefit of moving `maxSize` configuration to form init; allowing potential to surface node-level max size violations during form filling, rather than only at submission time - Account for controlled/uncontrolled repeat range variants, which was merged while the design has been in flight - As discussed in the design thread, revise `RootNode.prepareSubmission` to return a `Promise` - Add clarifying JSDoc detail to `RootNode.prepareSubmission`, largely expanding on reasoning behind `SubmissionResult` statuses (and production of submission result in “error” states), and reasoning for revision to return a `Promise` --- packages/xforms-engine/src/client/BaseNode.ts | 3 +- packages/xforms-engine/src/client/RootNode.ts | 33 +++++ .../xforms-engine/src/client/constants.ts | 6 + packages/xforms-engine/src/client/identity.ts | 16 +++ .../src/client/submission/SubmissionData.ts | 12 ++ .../client/submission/SubmissionDefinition.ts | 16 +++ .../submission/SubmissionInstanceFile.ts | 9 ++ .../client/submission/SubmissionOptions.ts | 28 ++++ .../src/client/submission/SubmissionResult.ts | 124 ++++++++++++++++++ .../xforms-engine/src/client/validation.ts | 4 +- packages/xforms-engine/src/index.ts | 6 + packages/xforms-engine/src/instance/Group.ts | 4 +- packages/xforms-engine/src/instance/Root.ts | 15 ++- .../xforms-engine/src/instance/Subtree.ts | 4 +- .../src/instance/abstract/InstanceNode.ts | 10 +- .../xforms-engine/src/instance/identity.ts | 12 +- .../internal-api/ValidationContext.ts | 4 +- .../src/instance/repeat/BaseRepeatRange.ts | 4 +- .../src/instance/repeat/RepeatInstance.ts | 4 +- .../src/lib/reactivity/createChildrenState.ts | 14 +- .../materializeCurrentStateChildren.ts | 14 +- 21 files changed, 299 insertions(+), 43 deletions(-) create mode 100644 packages/xforms-engine/src/client/identity.ts create mode 100644 packages/xforms-engine/src/client/submission/SubmissionData.ts create mode 100644 packages/xforms-engine/src/client/submission/SubmissionDefinition.ts create mode 100644 packages/xforms-engine/src/client/submission/SubmissionInstanceFile.ts create mode 100644 packages/xforms-engine/src/client/submission/SubmissionOptions.ts create mode 100644 packages/xforms-engine/src/client/submission/SubmissionResult.ts diff --git a/packages/xforms-engine/src/client/BaseNode.ts b/packages/xforms-engine/src/client/BaseNode.ts index 7c06ca7be..5fec6402c 100644 --- a/packages/xforms-engine/src/client/BaseNode.ts +++ b/packages/xforms-engine/src/client/BaseNode.ts @@ -3,6 +3,7 @@ import type { AnyNodeDefinition } from '../parse/model/NodeDefinition.ts'; import type { NodeAppearances } from './NodeAppearances.ts'; import type { OpaqueReactiveObjectFactory } from './OpaqueReactiveObjectFactory.ts'; import type { TextRange } from './TextRange.ts'; +import type { FormNodeID } from './identity.ts'; import type { InstanceNodeType } from './node-types.ts'; import type { AncestorNodeValidationState, @@ -129,8 +130,6 @@ export interface BaseNodeState { get value(): unknown; } -type FormNodeID = string; - /** * Base interface for common/shared aspects of any node type. */ diff --git a/packages/xforms-engine/src/client/RootNode.ts b/packages/xforms-engine/src/client/RootNode.ts index 32adee1a7..8ca57cfcf 100644 --- a/packages/xforms-engine/src/client/RootNode.ts +++ b/packages/xforms-engine/src/client/RootNode.ts @@ -3,6 +3,8 @@ import type { RootDefinition } from '../parse/model/RootDefinition.ts'; import type { BaseNode, BaseNodeState } from './BaseNode.ts'; import type { ActiveLanguage, FormLanguage, FormLanguages } from './FormLanguage.ts'; import type { GeneralChildNode } from './hierarchy.ts'; +import type { SubmissionChunkedType, SubmissionOptions } from './submission/SubmissionOptions.ts'; +import type { SubmissionResult } from './submission/SubmissionResult.ts'; import type { AncestorNodeValidationState } from './validation.ts'; export interface RootNodeState extends BaseNodeState { @@ -57,4 +59,35 @@ export interface RootNode extends BaseNode { readonly languages: FormLanguages; setLanguage(language: FormLanguage): RootNode; + + /** + * Prepares the current form instance state for submission. + * + * A {@link SubmissionResult} will be prepared even if the current form state + * includes `constraint` or `required` violations. This is intended to serve + * two purposes: + * + * - A client may effectively use this method as a part of its own "submit" + * routine, and use any violations included in the {@link SubmissionResult} + * to prompt users to address those violations. + * + * - A client may inspect the submission state of a form at any time. + * Depending on the client and use case, this may be a convenience (e.g. for + * developers to inspect that form state at a current point in time); or it + * may provide necessary functionality (e.g. for test or tooling clients). + * + * Note on asynchrony: preparing a {@link SubmissionResult} is expected to be + * a fast operation. It may even be nearly instantaneous, or roughly + * proportionate to the size of the form itself. However, this method is + * designed to be asynchronous out of an abundance of caution, anticipating + * that some as-yet undeveloped operations on binary data (e.g. form + * attachments) may themselves impose asynchrony (i.e. by interfaces provided + * by the platform and/or external dependencies). + * + * A client may specify {@link SubmissionOptions<'chunked'>}, in which case a + * {@link SubmissionResult<'chunked'>} will be produced, with form attachments + */ + prepareSubmission( + options?: SubmissionOptions + ): Promise>; } diff --git a/packages/xforms-engine/src/client/constants.ts b/packages/xforms-engine/src/client/constants.ts index 0fb7f1a15..1c722e1bb 100644 --- a/packages/xforms-engine/src/client/constants.ts +++ b/packages/xforms-engine/src/client/constants.ts @@ -8,3 +8,9 @@ export const VALIDATION_TEXT = { type ValidationTextDefaults = typeof VALIDATION_TEXT; export type ValidationTextDefault = ValidationTextDefaults[Role]; + +export const SUBMISSION_INSTANCE_FILE_NAME = 'xml_submission_file'; +export type SubmissionInstanceFileName = typeof SUBMISSION_INSTANCE_FILE_NAME; + +export const SUBMISSION_INSTANCE_FILE_TYPE = 'text/xml'; +export type SubmissionInstanceFileType = typeof SUBMISSION_INSTANCE_FILE_TYPE; diff --git a/packages/xforms-engine/src/client/identity.ts b/packages/xforms-engine/src/client/identity.ts new file mode 100644 index 000000000..e685582f6 --- /dev/null +++ b/packages/xforms-engine/src/client/identity.ts @@ -0,0 +1,16 @@ +type ODKXFormsUUID = `uuid:${string}`; + +/** + * @see {@link https://getodk.github.io/xforms-spec/#metadata} + */ +export type InstanceID = ODKXFormsUUID; + +/** + * @see {@link https://getodk.github.io/xforms-spec/#metadata} + */ +export type DeprecatedID = ODKXFormsUUID; + +/** + * Represents a session-stable identifier for any particular node i + */ +export type FormNodeID = `node:${string}`; diff --git a/packages/xforms-engine/src/client/submission/SubmissionData.ts b/packages/xforms-engine/src/client/submission/SubmissionData.ts new file mode 100644 index 000000000..d9dcb3408 --- /dev/null +++ b/packages/xforms-engine/src/client/submission/SubmissionData.ts @@ -0,0 +1,12 @@ +import type { + SubmissionInstanceFile, + SubmissionInstanceFileName, +} from './SubmissionInstanceFile.ts'; + +export interface SubmissionData extends FormData { + get(name: SubmissionInstanceFileName): SubmissionInstanceFile; + get(name: string): FormDataEntryValue | null; + + has(name: SubmissionInstanceFileName): true; + has(name: string): boolean; +} diff --git a/packages/xforms-engine/src/client/submission/SubmissionDefinition.ts b/packages/xforms-engine/src/client/submission/SubmissionDefinition.ts new file mode 100644 index 000000000..d458f77fd --- /dev/null +++ b/packages/xforms-engine/src/client/submission/SubmissionDefinition.ts @@ -0,0 +1,16 @@ +export interface SubmissionDefinition { + /** + * @see {@link https://getodk.github.io/xforms-spec/#submission-attributes | `action` submission attribute} + */ + readonly submissionAction: URL | null; + + /** + * @see {@link https://getodk.github.io/xforms-spec/#submission-attributes | `method` submission attribute} + */ + readonly submissionMethod: 'post'; + + /** + * @see {@link https://getodk.github.io/xforms-spec/#submission-attributes | `base64RsaPublicKey` submission attribute} + */ + readonly encryptionKey: string | null; +} diff --git a/packages/xforms-engine/src/client/submission/SubmissionInstanceFile.ts b/packages/xforms-engine/src/client/submission/SubmissionInstanceFile.ts new file mode 100644 index 000000000..6cd115a27 --- /dev/null +++ b/packages/xforms-engine/src/client/submission/SubmissionInstanceFile.ts @@ -0,0 +1,9 @@ +import type { SubmissionInstanceFileName, SubmissionInstanceFileType } from '../constants.ts'; + +// Re-export for convenient `SubmissionInstanceFile` construction/access flows +export type { SubmissionInstanceFileName, SubmissionInstanceFileType }; + +export interface SubmissionInstanceFile extends File { + readonly name: SubmissionInstanceFileName; + readonly type: SubmissionInstanceFileType; +} diff --git a/packages/xforms-engine/src/client/submission/SubmissionOptions.ts b/packages/xforms-engine/src/client/submission/SubmissionOptions.ts new file mode 100644 index 000000000..13184d379 --- /dev/null +++ b/packages/xforms-engine/src/client/submission/SubmissionOptions.ts @@ -0,0 +1,28 @@ +export type SubmissionChunkedType = 'chunked' | 'monolithic'; + +interface BaseSubmissionOptions { + readonly chunked?: ChunkedType | undefined; + + /** + * As described in the + * {@link https://docs.getodk.org/openrosa-form-submission/#extended-transmission-considerations | OpenRosa Form Submission API}, + * clients may obtain this value from an OpenRosa server's + * `X-OpenRosa-Accept-Content-Length` header. + */ + readonly maxSize?: number; +} + +interface ChunkedSubmissionOptions extends BaseSubmissionOptions<'chunked'> { + readonly maxSize: number; +} + +interface MonolithicSubmissionOptions extends BaseSubmissionOptions<'monolithic'> { + readonly chunked?: 'monolithic' | undefined; + readonly maxSize?: never; +} + +// prettier-ignore +export type SubmissionOptions = { + chunked: ChunkedSubmissionOptions; + monolithic: MonolithicSubmissionOptions; +}[ChunkedType]; diff --git a/packages/xforms-engine/src/client/submission/SubmissionResult.ts b/packages/xforms-engine/src/client/submission/SubmissionResult.ts new file mode 100644 index 000000000..54437d416 --- /dev/null +++ b/packages/xforms-engine/src/client/submission/SubmissionResult.ts @@ -0,0 +1,124 @@ +import type { AnyViolation, DescendantNodeViolationReference } from '../validation.ts'; +import type { SubmissionData } from './SubmissionData.ts'; +import type { SubmissionDefinition } from './SubmissionDefinition.ts'; +import type { SubmissionChunkedType, SubmissionOptions } from './SubmissionOptions.ts'; + +// prettier-ignore +export type SubmissionResultStatus = + // eslint-disable-next-line @typescript-eslint/sort-type-constituents + | 'pending' + | 'max-size-exceeded' + | 'ready'; + +// prettier-ignore +type SubmissionResultData = { + chunked: readonly [SubmissionData, ...SubmissionData[]]; + monolithic: SubmissionData; +}[ChunkedType]; + +/** + * Provides detail about an individual submission attachment {@link File}s which + * exceeds the client-specified {@link maxSize} for a + * {@link SubmissionResult<'chunked'> | chunked submission result}. Clients may + * use this value to provide guidance to users. + * + * @todo We may want to consider (a) making {@link maxSize} a configuration the + * client can provide when initializing a form instance, rather than only on + * submission; and then (b) treating a maximum size violation as another kind of + * node-level violation. This would go beyond the kinds of validation specified + * by ODK XForms, but it would make a lot of _conceptual sense_. + * + * It would almost certainly be helpful to alert users to violations as the + * occur, rather than only at submission time (where they have likely already + * moved on). This is something clients can do without engine support, but it + * would likely promote good usability patterns if the engine makes it an + * obvious and uniform option at the main engine/client entrypoint. + * + * @todo If we consider the above, we'd want to reframe _this interface_ to + * match the shape of other {@link AnyViolation | violations} (adding it as a + * member of that union). We'd also likely eliminate + * {@link MaxSizeExceededResult} in the process, since + * {@link PendingSubmissionResult} would then cover the case. + */ +interface MaxSizeViolation { + /** + * Specifies the index of + * {@link SubmissionResultData<'chunked'> | chunked submission data} where a + * submission attachment {@link File} exceeds the client-specified + * {@link maxSize}. + */ + readonly dataIndex: number; + + /** + * Specifies the name of the file which exceeds the client-specified + * {@link maxSize}. This name can also be used as a key to access the + * violating {@link File}/submission attachment, in the {@link SubmissionData} + * at the specified {@link dataIndex}. + */ + readonly fileName: string; + + /** + * Reflects the client-specified maximum size for each chunk of a + * {@link SubmissionResult<'chunked'> | chunked submission result}. + */ + readonly maxSize: number; + + /** + * Details the actual size of the violating {@link File}/submission + * attachment. Along with {@link maxSize}. Clients may use the delta between + * this value and {@link maxSize} to provide detailed guidance to users. + */ + readonly actualSize: number; +} + +// prettier-ignore +type SubmissionResultViolation = + | DescendantNodeViolationReference + | MaxSizeViolation; + +interface BaseSubmissionResult { + readonly status: SubmissionResultStatus; + readonly definition: SubmissionDefinition; + get violations(): readonly SubmissionResultViolation[] | null; + + /** + * Submission data may be chunked according to the + * {@link SubmissionOptions.maxSize | maxSize submission option} + */ + readonly data: SubmissionResultData; +} + +interface PendingSubmissionResult + extends BaseSubmissionResult { + readonly status: 'pending'; + get violations(): readonly DescendantNodeViolationReference[]; +} + +interface MaxSizeExceededResult extends BaseSubmissionResult<'chunked'> { + readonly status: 'max-size-exceeded'; + get violations(): readonly MaxSizeViolation[]; +} + +interface ReadySubmissionResult + extends BaseSubmissionResult { + readonly status: 'ready'; + get violations(): null; +} + +// prettier-ignore +type CommonSubmissionResult = + | PendingSubmissionResult + | ReadySubmissionResult; + +// prettier-ignore +export type ChunkedSubmissionResult = + | CommonSubmissionResult<'chunked'> + | MaxSizeExceededResult; + +export type MonolithicSubmissionResult = CommonSubmissionResult<'monolithic'>; + +// prettier-ignore +export type SubmissionResult = { + chunked: ChunkedSubmissionResult; + monolithic: MonolithicSubmissionResult; +}[ChunkedType]; diff --git a/packages/xforms-engine/src/client/validation.ts b/packages/xforms-engine/src/client/validation.ts index e29b009d9..99b4bc73b 100644 --- a/packages/xforms-engine/src/client/validation.ts +++ b/packages/xforms-engine/src/client/validation.ts @@ -1,5 +1,5 @@ -import type { NodeID } from '../instance/identity.ts'; import type { BaseNode, BaseNodeState } from './BaseNode.ts'; +import type { FormNodeID } from './identity.ts'; import type { OpaqueReactiveObjectFactory } from './OpaqueReactiveObjectFactory.ts'; import type { RootNode } from './RootNode.ts'; import type { TextRange } from './TextRange.ts'; @@ -176,7 +176,7 @@ export interface LeafNodeValidationState { * each property will be directly computed from the affected node. */ export interface DescendantNodeViolationReference { - readonly nodeId: NodeID; + readonly nodeId: FormNodeID; get reference(): string; get violation(): AnyViolation; diff --git a/packages/xforms-engine/src/index.ts b/packages/xforms-engine/src/index.ts index a30bbe9fd..767ba29e6 100644 --- a/packages/xforms-engine/src/index.ts +++ b/packages/xforms-engine/src/index.ts @@ -28,6 +28,12 @@ export type * from './client/repeat/RepeatRangeUncontrolledNode.ts'; export type * from './client/RootNode.ts'; export type * from './client/SelectNode.ts'; export type * from './client/StringNode.ts'; +export type * from './client/submission/SubmissionData.ts'; +export type * from './client/submission/SubmissionDefinition.ts'; +export type * from './client/submission/SubmissionInstanceFile.ts'; +export type * from './client/submission/SubmissionOptions.ts'; +export type * from './client/submission/SubmissionResult.ts'; +export type * from './client/submission/SubmissionState.ts'; export type * from './client/SubtreeNode.ts'; export type * from './client/TextRange.ts'; export type * from './client/TriggerNode.ts'; diff --git a/packages/xforms-engine/src/instance/Group.ts b/packages/xforms-engine/src/instance/Group.ts index 44b08f913..74c35225d 100644 --- a/packages/xforms-engine/src/instance/Group.ts +++ b/packages/xforms-engine/src/instance/Group.ts @@ -1,5 +1,6 @@ import type { Accessor } from 'solid-js'; import type { GroupDefinition, GroupNode, GroupNodeAppearances } from '../client/GroupNode.ts'; +import type { FormNodeID } from '../client/identity.ts'; import type { TextRange } from '../client/TextRange.ts'; import type { AncestorNodeValidationState } from '../client/validation.ts'; import type { ChildrenState } from '../lib/reactivity/createChildrenState.ts'; @@ -16,7 +17,6 @@ import type { DescendantNodeSharedStateSpec } from './abstract/DescendantNode.ts import { DescendantNode } from './abstract/DescendantNode.ts'; import { buildChildren } from './children.ts'; import type { GeneralChildNode, GeneralParentNode } from './hierarchy.ts'; -import type { NodeID } from './identity.ts'; import type { EvaluationContext } from './internal-api/EvaluationContext.ts'; import type { SubscribableDependency } from './internal-api/SubscribableDependency.ts'; @@ -24,7 +24,7 @@ import type { SubscribableDependency } from './internal-api/SubscribableDependen interface GroupStateSpec extends DescendantNodeSharedStateSpec { readonly label: Accessor | null>; readonly hint: null; - readonly children: Accessor; + readonly children: Accessor; readonly valueOptions: null; readonly value: null; } diff --git a/packages/xforms-engine/src/instance/Root.ts b/packages/xforms-engine/src/instance/Root.ts index 4828eb4e8..bad32e92d 100644 --- a/packages/xforms-engine/src/instance/Root.ts +++ b/packages/xforms-engine/src/instance/Root.ts @@ -3,6 +3,12 @@ import type { Accessor, Signal } from 'solid-js'; import { createSignal } from 'solid-js'; import type { ActiveLanguage, FormLanguage, FormLanguages } from '../client/FormLanguage.ts'; import type { RootNode } from '../client/RootNode.ts'; +import type { FormNodeID } from '../client/identity.ts'; +import type { + SubmissionChunkedType, + SubmissionOptions, +} from '../client/submission/SubmissionOptions.ts'; +import type { SubmissionResult } from '../client/submission/SubmissionResult.ts'; import type { AncestorNodeValidationState } from '../client/validation.ts'; import type { ChildrenState } from '../lib/reactivity/createChildrenState.ts'; import { createChildrenState } from '../lib/reactivity/createChildrenState.ts'; @@ -19,7 +25,6 @@ import type { XFormDOM } from '../parse/XFormDOM.ts'; import { InstanceNode } from './abstract/InstanceNode.ts'; import { buildChildren } from './children.ts'; import type { GeneralChildNode } from './hierarchy.ts'; -import type { NodeID } from './identity.ts'; import type { EvaluationContext, EvaluationContextRoot } from './internal-api/EvaluationContext.ts'; import type { InstanceConfig } from './internal-api/InstanceConfig.ts'; import type { SubscribableDependency } from './internal-api/SubscribableDependency.ts'; @@ -32,7 +37,7 @@ interface RootStateSpec { readonly required: boolean; readonly label: null; readonly hint: null; - readonly children: Accessor; + readonly children: Accessor; readonly valueOptions: null; readonly value: null; @@ -217,6 +222,12 @@ export class Root return this; } + prepareSubmission( + _options?: SubmissionOptions + ): Promise> { + throw new Error('Not implemented'); + } + // SubscribableDependency override subscribe(): void { super.subscribe(); diff --git a/packages/xforms-engine/src/instance/Subtree.ts b/packages/xforms-engine/src/instance/Subtree.ts index ea05774ad..d65d373a0 100644 --- a/packages/xforms-engine/src/instance/Subtree.ts +++ b/packages/xforms-engine/src/instance/Subtree.ts @@ -1,4 +1,5 @@ import { type Accessor } from 'solid-js'; +import type { FormNodeID } from '../client/identity.ts'; import type { SubtreeDefinition, SubtreeNode } from '../client/SubtreeNode.ts'; import type { AncestorNodeValidationState } from '../client/validation.ts'; import type { ChildrenState } from '../lib/reactivity/createChildrenState.ts'; @@ -14,14 +15,13 @@ import type { DescendantNodeSharedStateSpec } from './abstract/DescendantNode.ts import { DescendantNode } from './abstract/DescendantNode.ts'; import { buildChildren } from './children.ts'; import type { GeneralChildNode, GeneralParentNode } from './hierarchy.ts'; -import type { NodeID } from './identity.ts'; import type { EvaluationContext } from './internal-api/EvaluationContext.ts'; import type { SubscribableDependency } from './internal-api/SubscribableDependency.ts'; interface SubtreeStateSpec extends DescendantNodeSharedStateSpec { readonly label: null; readonly hint: null; - readonly children: Accessor; + readonly children: Accessor; readonly valueOptions: null; readonly value: null; } diff --git a/packages/xforms-engine/src/instance/abstract/InstanceNode.ts b/packages/xforms-engine/src/instance/abstract/InstanceNode.ts index 543988f3d..76e3e2f43 100644 --- a/packages/xforms-engine/src/instance/abstract/InstanceNode.ts +++ b/packages/xforms-engine/src/instance/abstract/InstanceNode.ts @@ -2,6 +2,7 @@ import type { XFormsXPathEvaluator } from '@getodk/xpath'; import type { Accessor, Signal } from 'solid-js'; import type { BaseNode } from '../../client/BaseNode.ts'; import type { NodeAppearances } from '../../client/NodeAppearances.ts'; +import type { FormNodeID } from '../../client/identity.ts'; import type { InstanceNodeType } from '../../client/node-types.ts'; import type { NodeValidationState } from '../../client/validation.ts'; import type { TextRange } from '../../index.ts'; @@ -15,8 +16,7 @@ import type { SimpleAtomicState } from '../../lib/reactivity/types.ts'; import type { AnyNodeDefinition } from '../../parse/model/NodeDefinition.ts'; import type { Root } from '../Root.ts'; import type { AnyChildNode, AnyNode, AnyParentNode } from '../hierarchy.ts'; -import type { NodeID } from '../identity.ts'; -import { declareNodeID } from '../identity.ts'; +import { nodeID } from '../identity.ts'; import type { EvaluationContext } from '../internal-api/EvaluationContext.ts'; import type { InstanceConfig } from '../internal-api/InstanceConfig.ts'; import type { SubscribableDependency } from '../internal-api/SubscribableDependency.ts'; @@ -28,7 +28,7 @@ export interface InstanceNodeStateSpec { readonly required: Accessor | boolean; readonly label: Accessor | null> | null; readonly hint: Accessor | null> | null; - readonly children: Accessor | null; + readonly children: Accessor | null; readonly valueOptions: Accessor | Accessor | null; readonly value: Signal | SimpleAtomicState | null; } @@ -109,7 +109,7 @@ export abstract class InstanceNode< abstract readonly isRelevant: Accessor; // BaseNode: identity - readonly nodeId: NodeID; + readonly nodeId: FormNodeID; // BaseNode: node types and variants (e.g. for narrowing) abstract readonly nodeType: InstanceNodeType; @@ -161,7 +161,7 @@ export abstract class InstanceNode< this.scope = createReactiveScope(); this.engineConfig = engineConfig; - this.nodeId = declareNodeID(engineConfig.createUniqueId()); + this.nodeId = nodeID(engineConfig.createUniqueId()); this.definition = definition; } diff --git a/packages/xforms-engine/src/instance/identity.ts b/packages/xforms-engine/src/instance/identity.ts index 388321402..924c2828f 100644 --- a/packages/xforms-engine/src/instance/identity.ts +++ b/packages/xforms-engine/src/instance/identity.ts @@ -1,11 +1,5 @@ -declare const NODE_ID_BRAND: unique symbol; -type NODE_ID_BRAND = typeof NODE_ID_BRAND; +import type { FormNodeID } from '../client/identity.ts'; -export type NodeID = string & { readonly [NODE_ID_BRAND]: true }; - -// Just another added safeguard to ensure we're not mistakenly handling -// rando `NodeID` strings which aren't explicitly attached to the node -// types we expect. -export const declareNodeID = (id: string): NodeID => { - return id as NodeID; +export const nodeID = (id: string): FormNodeID => { + return `node:${id}` satisfies FormNodeID; }; diff --git a/packages/xforms-engine/src/instance/internal-api/ValidationContext.ts b/packages/xforms-engine/src/instance/internal-api/ValidationContext.ts index b0562ee79..00314fc7e 100644 --- a/packages/xforms-engine/src/instance/internal-api/ValidationContext.ts +++ b/packages/xforms-engine/src/instance/internal-api/ValidationContext.ts @@ -1,7 +1,7 @@ +import type { FormNodeID } from '../../client/identity.ts'; import type { AnyViolation } from '../../client/validation.ts'; import type { BindComputationExpression } from '../../parse/expression/BindComputationExpression.ts'; import type { MessageDefinition } from '../../parse/text/MessageDefinition.ts'; -import type { NodeID } from '../identity.ts'; import type { EvaluationContext } from './EvaluationContext.ts'; import type { SubscribableDependency } from './SubscribableDependency.ts'; @@ -21,7 +21,7 @@ interface ValidationContextDefinition { } export interface ValidationContext extends EvaluationContext, SubscribableDependency { - readonly nodeId: NodeID; + readonly nodeId: FormNodeID; readonly definition: ValidationContextDefinition; readonly currentState: ValidationContextCurrentState; diff --git a/packages/xforms-engine/src/instance/repeat/BaseRepeatRange.ts b/packages/xforms-engine/src/instance/repeat/BaseRepeatRange.ts index f0a4ad321..1000480da 100644 --- a/packages/xforms-engine/src/instance/repeat/BaseRepeatRange.ts +++ b/packages/xforms-engine/src/instance/repeat/BaseRepeatRange.ts @@ -1,5 +1,6 @@ import { insertAtIndex } from '@getodk/common/lib/array/insert.ts'; import { untrack, type Accessor } from 'solid-js'; +import type { FormNodeID } from '../../client/identity.ts'; import type { NodeAppearances } from '../../client/NodeAppearances.ts'; import type { BaseRepeatRangeNode } from '../../client/repeat/BaseRepeatRangeNode.ts'; import type { TextRange } from '../../client/TextRange.ts'; @@ -25,7 +26,6 @@ import type { } from '../abstract/DescendantNode.ts'; import { DescendantNode } from '../abstract/DescendantNode.ts'; import type { RepeatRange } from '../hierarchy.ts'; -import type { NodeID } from '../identity.ts'; import type { EvaluationContext } from '../internal-api/EvaluationContext.ts'; import type { SubscribableDependency } from '../internal-api/SubscribableDependency.ts'; import { RepeatInstance, type RepeatDefinition } from './RepeatInstance.ts'; @@ -33,7 +33,7 @@ import { RepeatInstance, type RepeatDefinition } from './RepeatInstance.ts'; interface RepeatRangeStateSpec extends DescendantNodeSharedStateSpec { readonly hint: null; readonly label: Accessor | null>; - readonly children: Accessor; + readonly children: Accessor; readonly valueOptions: null; readonly value: null; } diff --git a/packages/xforms-engine/src/instance/repeat/RepeatInstance.ts b/packages/xforms-engine/src/instance/repeat/RepeatInstance.ts index 8c87c3e05..ce3c538ec 100644 --- a/packages/xforms-engine/src/instance/repeat/RepeatInstance.ts +++ b/packages/xforms-engine/src/instance/repeat/RepeatInstance.ts @@ -1,5 +1,6 @@ import type { Accessor } from 'solid-js'; import { createComputed, createSignal, on } from 'solid-js'; +import type { FormNodeID } from '../../client/identity.ts'; import type { RepeatDefinition, RepeatInstanceNode, @@ -21,7 +22,6 @@ import type { DescendantNodeSharedStateSpec } from '../abstract/DescendantNode.t import { DescendantNode } from '../abstract/DescendantNode.ts'; import { buildChildren } from '../children.ts'; import type { AnyChildNode, GeneralChildNode, RepeatRange } from '../hierarchy.ts'; -import type { NodeID } from '../identity.ts'; import type { EvaluationContext } from '../internal-api/EvaluationContext.ts'; import type { SubscribableDependency } from '../internal-api/SubscribableDependency.ts'; @@ -30,7 +30,7 @@ export type { RepeatDefinition }; interface RepeatInstanceStateSpec extends DescendantNodeSharedStateSpec { readonly label: Accessor | null>; readonly hint: null; - readonly children: Accessor; + readonly children: Accessor; readonly valueOptions: null; readonly value: null; } diff --git a/packages/xforms-engine/src/lib/reactivity/createChildrenState.ts b/packages/xforms-engine/src/lib/reactivity/createChildrenState.ts index 1de5215d5..f8d558804 100644 --- a/packages/xforms-engine/src/lib/reactivity/createChildrenState.ts +++ b/packages/xforms-engine/src/lib/reactivity/createChildrenState.ts @@ -1,8 +1,8 @@ import type { Accessor, Setter, Signal } from 'solid-js'; import { createSignal } from 'solid-js'; +import type { FormNodeID } from '../../client/identity.ts'; import type { OpaqueReactiveObjectFactory } from '../../index.ts'; import type { AnyChildNode, AnyParentNode } from '../../instance/hierarchy.ts'; -import type { NodeID } from '../../instance/identity.ts'; import type { materializeCurrentStateChildren } from './materializeCurrentStateChildren.ts'; import type { ClientState } from './node-state/createClientState.ts'; import type { CurrentState } from './node-state/createCurrentState.ts'; @@ -12,14 +12,15 @@ export interface ChildrenState { readonly children: Signal; readonly getChildren: Accessor; readonly setChildren: Setter; - readonly childIds: Accessor; + readonly childIds: Accessor; } /** * Creates a synchronized pair of: * * - Internal children state suitable for all parent node types - * - The same children state computed as an array of each child's {@link NodeID} + * - The same children state computed as an array of each child's + * {@link FormNodeID} * * This state is used, in tandem with {@link materializeCurrentStateChildren}, * to ensure children in **client-facing** state are not written into nested @@ -34,11 +35,12 @@ export interface ChildrenState { * The produced {@link ChildrenState.childIds} memo is intended to be used to * specify each parent node's `children` in an instance of {@link EngineState}. * In so doing, the node's corresponding (internal, synchronized) - * {@link ClientState} will likewise store only the children's {@link NodeID}s. + * {@link ClientState} will likewise store only the children's + * {@link FormNodeID}s. * * As a client reacts to changes in a given parent node's `children` state, that * node's {@link CurrentState} should produce the child nodes corresponding to - * those {@link NodeID}s with the aforementioned + * those {@link FormNodeID}s with the aforementioned * {@link materializeCurrentStateChildren}. */ export const createChildrenState = ( @@ -65,7 +67,7 @@ export const createChildrenState = ([]); + const ids = createSignal([]); const [childIds, setChildIds] = ids; type ChildrenSetterCallback = (prev: readonly Child[]) => readonly Child[]; diff --git a/packages/xforms-engine/src/lib/reactivity/materializeCurrentStateChildren.ts b/packages/xforms-engine/src/lib/reactivity/materializeCurrentStateChildren.ts index 58f614d3d..78a74e1d8 100644 --- a/packages/xforms-engine/src/lib/reactivity/materializeCurrentStateChildren.ts +++ b/packages/xforms-engine/src/lib/reactivity/materializeCurrentStateChildren.ts @@ -1,13 +1,13 @@ +import type { FormNodeID } from '../../client/identity.ts'; import type { AnyChildNode } from '../../instance/hierarchy.ts'; -import type { NodeID } from '../../instance/identity.ts'; import type { ChildrenState, createChildrenState } from './createChildrenState.ts'; import type { ClientState } from './node-state/createClientState.ts'; import type { CurrentState } from './node-state/createCurrentState.ts'; import type { ReactiveScope } from './scope.ts'; interface InconsistentChildrenStateDetails { - readonly missingIds: readonly NodeID[]; - readonly unexpectedIds: readonly NodeID[]; + readonly missingIds: readonly FormNodeID[]; + readonly unexpectedIds: readonly FormNodeID[]; } class InconsistentChildrenStateError extends Error { @@ -37,7 +37,7 @@ class InconsistentChildrenStateError extends Error { } export interface EncodedParentState { - readonly children: readonly NodeID[]; + readonly children: readonly FormNodeID[]; } /** @@ -58,7 +58,7 @@ export interface EncodedParentState { * @todo should we throw rather than warn until we have this confidence? */ const reportInconsistentChildrenState = ( - expectedClientIds: readonly NodeID[], + expectedClientIds: readonly FormNodeID[], actualNodes: readonly AnyChildNode[] ): void => { const actualIds = actualNodes.map((node) => node.nodeId); @@ -88,8 +88,8 @@ export type MaterializedChildren< /** * Creates a wrapper proxy around a parent node's {@link CurrentState} to map * `children` state, which is written to the node's (internal, synchronized) - * {@link ClientState} as an array of {@link NodeID}s, back to the node objects - * corresponding to those IDs. + * {@link ClientState} as an array of {@link FormNodeID}s, back to the node + * objects corresponding to those IDs. * * @see {@link createChildrenState} for further detail. */ From 4021e1730160a4642ebf2175f15f9efc628eba25 Mon Sep 17 00:00:00 2001 From: eyelidlessness Date: Mon, 23 Sep 2024 10:47:21 -0700 Subject: [PATCH 03/18] escapeXMLText MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This would be more suitable for `@getodk/common`, except that it makes assumptions about serializing attributes. Note that we don’t currently serialize attributes, but the base implementation already handles the difference so the only additional work here was documenting and testing the behavior. --- .../src/lib/xml-serialization.ts | 76 +++++++++++++++++++ .../test/lib/xml-serialization.test.ts | 37 +++++++++ 2 files changed, 113 insertions(+) create mode 100644 packages/xforms-engine/src/lib/xml-serialization.ts create mode 100644 packages/xforms-engine/test/lib/xml-serialization.test.ts diff --git a/packages/xforms-engine/src/lib/xml-serialization.ts b/packages/xforms-engine/src/lib/xml-serialization.ts new file mode 100644 index 000000000..bf8e64501 --- /dev/null +++ b/packages/xforms-engine/src/lib/xml-serialization.ts @@ -0,0 +1,76 @@ +declare const ESCAPED_XML_TEXT_BRAND: unique symbol; + +type EscapedXMLText = string & { readonly [ESCAPED_XML_TEXT_BRAND]: true }; + +const ATTR_REGEX = /[&<>"]/; +const CONTENT_REGEX = /[&<>]/; + +/** + * This is based on the `escapeHTML` implementation in + * {@link https://github.com/ryansolid/dom-expressions} (Solid's JSX transform). + * + * @see {@link https://github.com/ryansolid/dom-expressions/pull/27} for + * motivation to derive this implementation approach. + * + * The intent is that this can be updated easily if the base implementation + * changes. As such, some aspects of this implementation differ from some of our + * typical code style preferences. + * + * Notable changes from the base implementation: + * + * - Formatting: automated only. + * - Naming: the {@link text} parameter is named `html` in the base + * implementation. That would be confusing if preserved. + * - Types: + * - Parameter types are added (of course) + * - Return type is branded as {@link EscapedXMLText}, to allow downstream + * checks that escaping has been performed. Return statements are cast + * accordingly. + * - {@link text} attempts to minimize risk of double-escaping by excluding + * that same branded type. + * - The '>' character is also escaped, necessary for producing valid XML. + * + * As with the base implementation, we leave some characters unescaped: + * + * - " (double quote): except when {@link attr} is `true`. + * + * - ' (single quote): on the assumption that attributes are always serialized + * in double quotes. If we ever move this to `@getodk/common`, we'd want to + * reconsider this assumption. + */ +export const escapeXMLText = ( + text: Exclude, + attr?: boolean +): EscapedXMLText => { + const match = (attr ? ATTR_REGEX : CONTENT_REGEX).exec(text); + if (!match) return text as string as EscapedXMLText; + let index = 0; + let lastIndex = 0; + let out = ''; + let escape = ''; + for (index = match.index; index < text.length; index++) { + switch (text.charCodeAt(index)) { + case 34: // " + if (!attr) continue; + escape = '"'; + break; + case 38: // & + escape = '&'; + break; + case 60: // < + escape = '<'; + break; + case 62: // > + escape = '>'; + break; + default: + continue; + } + if (lastIndex !== index) out += text.substring(lastIndex, index); + lastIndex = index + 1; + out += escape; + } + return lastIndex !== index + ? ((out + text.substring(lastIndex, index)) as EscapedXMLText) + : (out as EscapedXMLText); +}; diff --git a/packages/xforms-engine/test/lib/xml-serialization.test.ts b/packages/xforms-engine/test/lib/xml-serialization.test.ts new file mode 100644 index 000000000..213e27746 --- /dev/null +++ b/packages/xforms-engine/test/lib/xml-serialization.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { escapeXMLText } from '../../src/lib/xml-serialization.ts'; + +describe('XML serialization', () => { + describe('escapeXMLText', () => { + interface TestCase { + readonly input: string; + readonly isAttributeValue: boolean; + readonly expected: string; + } + + it.each([ + { + input: '10 < x < 12', + isAttributeValue: false, + expected: '10 < x < 12', + }, + { + input: 'x < "10" & x > \'12\'', + isAttributeValue: false, + expected: 'x < "10" & x > \'12\'', + }, + { + input: 'x < "10" & x > \'12\'', + isAttributeValue: true, + expected: "x < "10" & x > '12'", + }, + ])( + 'escapes $input to $expected (is attribute value? $isAttributeValue)', + ({ input, isAttributeValue, expected }) => { + const actual = escapeXMLText(input, isAttributeValue); + + expect(actual).toBe(expected); + } + ); + }); +}); From 97473ce93e67706cd8b3d18d1e567e75846f18c2 Mon Sep 17 00:00:00 2001 From: eyelidlessness Date: Mon, 23 Sep 2024 14:48:16 -0700 Subject: [PATCH 04/18] Engine/client: introduce intermediate XML-only serialized submission state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This intermediate state will be: - client-reactive - derived from other client-facing state/interfaces (so in theory a client could implement this directly; ui-solid has a partial implementation already) This aspect of the client API isn’t part of the core design we decided on, but it will both support that design as well as give some clear assurances that we want the functionality to be (and remain) client-reactive. In the future, we may determine it’s safe to make all submission functionality synchronous, in which case we can make it all client-reactive as well. This would form the basis for that. In contrast, if we decide not to make submission functionality client-reactive at the API level, we may want to consider folding the `submissionXML` accessor into `BaseNodeState`. Challenges with that are noted here. It is certainly conceivable (we have one other derived client-reactive state field: `children`). But it doesn’t feel like the most valuable yak to shave for actually getting the serialization aspect working. --- packages/xforms-engine/src/client/BaseNode.ts | 13 +++++++++++++ .../src/client/submission/SubmissionState.ts | 14 ++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 packages/xforms-engine/src/client/submission/SubmissionState.ts diff --git a/packages/xforms-engine/src/client/BaseNode.ts b/packages/xforms-engine/src/client/BaseNode.ts index 5fec6402c..69f1f1393 100644 --- a/packages/xforms-engine/src/client/BaseNode.ts +++ b/packages/xforms-engine/src/client/BaseNode.ts @@ -5,6 +5,7 @@ import type { OpaqueReactiveObjectFactory } from './OpaqueReactiveObjectFactory. import type { TextRange } from './TextRange.ts'; import type { FormNodeID } from './identity.ts'; import type { InstanceNodeType } from './node-types.ts'; +import type { SubmissionState } from './submission/SubmissionState.ts'; import type { AncestorNodeValidationState, LeafNodeValidationState, @@ -250,4 +251,16 @@ export interface BaseNode { * clients to explicitly pause and resume recomputation. */ readonly validationState: NodeValidationState; + + /** + * Represents the current submission state of the node. + * + * @see {@link SubmissionState.submissionXML} for additional detail. + * + * @todo Consider whether this can (should) be merged with + * {@link currentState}, while providing the same client-reactivity + * guarantees. (The challenge there is in defining client-reactive state which + * self-referentially derives state from its own definition.) + */ + readonly submissionState: SubmissionState; } diff --git a/packages/xforms-engine/src/client/submission/SubmissionState.ts b/packages/xforms-engine/src/client/submission/SubmissionState.ts new file mode 100644 index 000000000..5944fd845 --- /dev/null +++ b/packages/xforms-engine/src/client/submission/SubmissionState.ts @@ -0,0 +1,14 @@ +import type { RootNode } from '../RootNode.ts'; + +export interface SubmissionState { + /** + * Represents the serialized XML state of a given node, as it will be prepared + * for submission. The value produced in {@link RootNode.submissionState} is + * the same serialization which will be produced for the complete submission. + * + * @todo Note that this particular aspect of the design doesn't yet address + * production of unique file names. As such, this may change as we introduce + * affected data types (and their supporting nodes). + */ + get submissionXML(): string; +} From 67678a2a4a5d45ece1be7202cdf759db4405ab8e Mon Sep 17 00:00:00 2001 From: eyelidlessness Date: Wed, 25 Sep 2024 15:36:21 -0700 Subject: [PATCH 05/18] Implement submission XML serialization; tests pending The functionality has been validated in both `web-forms` and `ui-solid`. But automated tests will further help to ensure the behavior, as well as the reactive contract. This commit originally included unit-ish level tests within `@getodk/xforms-engine`. They have been removed for reasons discussed in the later commit expanding scenario coverage. --- packages/xforms-engine/src/instance/Group.ts | 11 +++++- .../xforms-engine/src/instance/ModelValue.ts | 9 ++++- packages/xforms-engine/src/instance/Note.ts | 9 ++++- packages/xforms-engine/src/instance/Root.ts | 10 ++++- .../xforms-engine/src/instance/SelectField.ts | 9 ++++- .../xforms-engine/src/instance/StringField.ts | 9 ++++- .../xforms-engine/src/instance/Subtree.ts | 11 +++++- .../src/instance/TriggerControl.ts | 9 ++++- .../src/instance/abstract/InstanceNode.ts | 3 ++ .../instance/abstract/UnsupportedControl.ts | 9 ++++- .../ClientReactiveSubmittableLeafNode.ts | 37 +++++++++++++++++++ .../ClientReactiveSubmittableParentNode.ts | 24 ++++++++++++ .../src/instance/repeat/BaseRepeatRange.ts | 12 +++++- .../src/instance/repeat/RepeatInstance.ts | 11 +++++- .../src/lib/client-reactivity/README.md | 0 .../createLeafNodeSubmissionState.ts | 20 ++++++++++ .../createNodeRangeSubmissionState.ts | 17 +++++++++ .../createParentNodeSubmissionState.ts | 22 +++++++++++ .../src/lib/xml-serialization.ts | 22 ++++++++++- 19 files changed, 241 insertions(+), 13 deletions(-) create mode 100644 packages/xforms-engine/src/instance/internal-api/submission/ClientReactiveSubmittableLeafNode.ts create mode 100644 packages/xforms-engine/src/instance/internal-api/submission/ClientReactiveSubmittableParentNode.ts create mode 100644 packages/xforms-engine/src/lib/client-reactivity/README.md create mode 100644 packages/xforms-engine/src/lib/client-reactivity/submission/createLeafNodeSubmissionState.ts create mode 100644 packages/xforms-engine/src/lib/client-reactivity/submission/createNodeRangeSubmissionState.ts create mode 100644 packages/xforms-engine/src/lib/client-reactivity/submission/createParentNodeSubmissionState.ts diff --git a/packages/xforms-engine/src/instance/Group.ts b/packages/xforms-engine/src/instance/Group.ts index 74c35225d..f8bd4c345 100644 --- a/packages/xforms-engine/src/instance/Group.ts +++ b/packages/xforms-engine/src/instance/Group.ts @@ -1,8 +1,10 @@ import type { Accessor } from 'solid-js'; import type { GroupDefinition, GroupNode, GroupNodeAppearances } from '../client/GroupNode.ts'; import type { FormNodeID } from '../client/identity.ts'; +import type { SubmissionState } from '../client/submission/SubmissionState.ts'; import type { TextRange } from '../client/TextRange.ts'; import type { AncestorNodeValidationState } from '../client/validation.ts'; +import { createParentNodeSubmissionState } from '../lib/client-reactivity/submission/createParentNodeSubmissionState.ts'; import type { ChildrenState } from '../lib/reactivity/createChildrenState.ts'; import { createChildrenState } from '../lib/reactivity/createChildrenState.ts'; import type { MaterializedChildren } from '../lib/reactivity/materializeCurrentStateChildren.ts'; @@ -18,6 +20,7 @@ import { DescendantNode } from './abstract/DescendantNode.ts'; import { buildChildren } from './children.ts'; import type { GeneralChildNode, GeneralParentNode } from './hierarchy.ts'; import type { EvaluationContext } from './internal-api/EvaluationContext.ts'; +import type { ClientReactiveSubmittableParentNode } from './internal-api/submission/ClientReactiveSubmittableParentNode.ts'; import type { SubscribableDependency } from './internal-api/SubscribableDependency.ts'; // prettier-ignore @@ -31,7 +34,11 @@ interface GroupStateSpec extends DescendantNodeSharedStateSpec { export class Group extends DescendantNode - implements GroupNode, EvaluationContext, SubscribableDependency + implements + GroupNode, + EvaluationContext, + SubscribableDependency, + ClientReactiveSubmittableParentNode { private readonly childrenState: ChildrenState; @@ -44,6 +51,7 @@ export class Group readonly appearances: GroupNodeAppearances; readonly currentState: MaterializedChildren, GeneralChildNode>; readonly validationState: AncestorNodeValidationState; + readonly submissionState: SubmissionState; constructor(parent: GeneralParentNode, definition: GroupDefinition) { super(parent, definition); @@ -85,6 +93,7 @@ export class Group childrenState.setChildren(buildChildren(this)); this.validationState = createAggregatedViolations(this, sharedStateOptions); + this.submissionState = createParentNodeSubmissionState(this); } getChildren(): readonly GeneralChildNode[] { diff --git a/packages/xforms-engine/src/instance/ModelValue.ts b/packages/xforms-engine/src/instance/ModelValue.ts index 1d6ed3da5..aaa9ed6a0 100644 --- a/packages/xforms-engine/src/instance/ModelValue.ts +++ b/packages/xforms-engine/src/instance/ModelValue.ts @@ -1,6 +1,8 @@ import { identity } from '@getodk/common/lib/identity.ts'; import type { ModelValueNode } from '../client/ModelValueNode.ts'; +import type { SubmissionState } from '../client/submission/SubmissionState.ts'; import type { AnyViolation, LeafNodeValidationState } from '../client/validation.ts'; +import { createLeafNodeSubmissionState } from '../lib/client-reactivity/submission/createLeafNodeSubmissionState.ts'; import { createValueState } from '../lib/reactivity/createValueState.ts'; import type { CurrentState } from '../lib/reactivity/node-state/createCurrentState.ts'; import type { EngineState } from '../lib/reactivity/node-state/createEngineState.ts'; @@ -17,6 +19,7 @@ import type { EvaluationContext } from './internal-api/EvaluationContext.ts'; import type { SubscribableDependency } from './internal-api/SubscribableDependency.ts'; import type { ValidationContext } from './internal-api/ValidationContext.ts'; import type { ValueContext } from './internal-api/ValueContext.ts'; +import type { ClientReactiveSubmittableLeafNode } from './internal-api/submission/ClientReactiveSubmittableLeafNode.ts'; export interface ModelValueDefinition extends LeafNodeDefinition { readonly bodyElement: null; @@ -37,7 +40,8 @@ export class ModelValue EvaluationContext, SubscribableDependency, ValidationContext, - ValueContext + ValueContext, + ClientReactiveSubmittableLeafNode { private readonly validation: SharedValidationState; protected readonly state: SharedNodeState; @@ -54,6 +58,8 @@ export class ModelValue return this.validation.currentState; } + readonly submissionState: SubmissionState; + // ValueContext readonly encodeValue = identity; readonly decodeValue = identity; @@ -86,6 +92,7 @@ export class ModelValue this.engineState = state.engineState; this.currentState = state.currentState; this.validation = createValidationState(this, sharedStateOptions); + this.submissionState = createLeafNodeSubmissionState(this); } // ValidationContext diff --git a/packages/xforms-engine/src/instance/Note.ts b/packages/xforms-engine/src/instance/Note.ts index 6b6dfec98..f32c5f995 100644 --- a/packages/xforms-engine/src/instance/Note.ts +++ b/packages/xforms-engine/src/instance/Note.ts @@ -2,8 +2,10 @@ import { UnreachableError } from '@getodk/common/lib/error/UnreachableError.ts'; import { identity } from '@getodk/common/lib/identity.ts'; import type { Accessor } from 'solid-js'; import type { NoteNode, NoteNodeAppearances } from '../client/NoteNode.ts'; +import type { SubmissionState } from '../client/submission/SubmissionState.ts'; import type { TextRange } from '../client/TextRange.ts'; import type { AnyViolation, LeafNodeValidationState } from '../client/validation.ts'; +import { createLeafNodeSubmissionState } from '../lib/client-reactivity/submission/createLeafNodeSubmissionState.ts'; import { createNoteReadonlyThunk } from '../lib/reactivity/createNoteReadonlyThunk.ts'; import { createValueState } from '../lib/reactivity/createValueState.ts'; import type { CurrentState } from '../lib/reactivity/node-state/createCurrentState.ts'; @@ -21,6 +23,7 @@ import type { DescendantNodeStateSpec } from './abstract/DescendantNode.ts'; import { DescendantNode } from './abstract/DescendantNode.ts'; import type { GeneralParentNode } from './hierarchy.ts'; import type { EvaluationContext } from './internal-api/EvaluationContext.ts'; +import type { ClientReactiveSubmittableLeafNode } from './internal-api/submission/ClientReactiveSubmittableLeafNode.ts'; import type { SubscribableDependency } from './internal-api/SubscribableDependency.ts'; import type { ValidationContext } from './internal-api/ValidationContext.ts'; import type { ValueContext } from './internal-api/ValueContext.ts'; @@ -42,7 +45,8 @@ export class Note EvaluationContext, SubscribableDependency, ValidationContext, - ValueContext + ValueContext, + ClientReactiveSubmittableLeafNode { private readonly validation: SharedValidationState; protected readonly state: SharedNodeState; @@ -59,6 +63,8 @@ export class Note return this.validation.currentState; } + readonly submissionState: SubmissionState; + // ValueContext readonly encodeValue = identity; @@ -124,6 +130,7 @@ export class Note this.engineState = state.engineState; this.currentState = state.currentState; this.validation = createValidationState(this, sharedStateOptions); + this.submissionState = createLeafNodeSubmissionState(this); } // ValidationContext diff --git a/packages/xforms-engine/src/instance/Root.ts b/packages/xforms-engine/src/instance/Root.ts index bad32e92d..bacb66db4 100644 --- a/packages/xforms-engine/src/instance/Root.ts +++ b/packages/xforms-engine/src/instance/Root.ts @@ -2,14 +2,16 @@ import type { XFormsXPathEvaluator } from '@getodk/xpath'; import type { Accessor, Signal } from 'solid-js'; import { createSignal } from 'solid-js'; import type { ActiveLanguage, FormLanguage, FormLanguages } from '../client/FormLanguage.ts'; -import type { RootNode } from '../client/RootNode.ts'; import type { FormNodeID } from '../client/identity.ts'; +import type { RootNode } from '../client/RootNode.ts'; import type { SubmissionChunkedType, SubmissionOptions, } from '../client/submission/SubmissionOptions.ts'; import type { SubmissionResult } from '../client/submission/SubmissionResult.ts'; +import type { SubmissionState } from '../client/submission/SubmissionState.ts'; import type { AncestorNodeValidationState } from '../client/validation.ts'; +import { createParentNodeSubmissionState } from '../lib/client-reactivity/submission/createParentNodeSubmissionState.ts'; import type { ChildrenState } from '../lib/reactivity/createChildrenState.ts'; import { createChildrenState } from '../lib/reactivity/createChildrenState.ts'; import type { MaterializedChildren } from '../lib/reactivity/materializeCurrentStateChildren.ts'; @@ -27,6 +29,7 @@ import { buildChildren } from './children.ts'; import type { GeneralChildNode } from './hierarchy.ts'; import type { EvaluationContext, EvaluationContextRoot } from './internal-api/EvaluationContext.ts'; import type { InstanceConfig } from './internal-api/InstanceConfig.ts'; +import type { ClientReactiveSubmittableParentNode } from './internal-api/submission/ClientReactiveSubmittableParentNode.ts'; import type { SubscribableDependency } from './internal-api/SubscribableDependency.ts'; import type { TranslationContext } from './internal-api/TranslationContext.ts'; @@ -104,7 +107,8 @@ export class Root EvaluationContext, EvaluationContextRoot, SubscribableDependency, - TranslationContext + TranslationContext, + ClientReactiveSubmittableParentNode { private readonly childrenState: ChildrenState; @@ -122,6 +126,7 @@ export class Root readonly classes: BodyClassList; readonly currentState: MaterializedChildren, GeneralChildNode>; readonly validationState: AncestorNodeValidationState; + readonly submissionState: SubmissionState; protected readonly instanceDOM: XFormDOM; @@ -200,6 +205,7 @@ export class Root childrenState.setChildren(buildChildren(this)); this.validationState = createAggregatedViolations(this, sharedStateOptions); + this.submissionState = createParentNodeSubmissionState(this); } getChildren(): readonly GeneralChildNode[] { diff --git a/packages/xforms-engine/src/instance/SelectField.ts b/packages/xforms-engine/src/instance/SelectField.ts index 89222ab51..08f4472be 100644 --- a/packages/xforms-engine/src/instance/SelectField.ts +++ b/packages/xforms-engine/src/instance/SelectField.ts @@ -3,7 +3,9 @@ import type { Accessor } from 'solid-js'; import { untrack } from 'solid-js'; import type { SelectItem, SelectNode, SelectNodeAppearances } from '../client/SelectNode.ts'; import type { TextRange } from '../client/TextRange.ts'; +import type { SubmissionState } from '../client/submission/SubmissionState.ts'; import type { AnyViolation, LeafNodeValidationState } from '../client/validation.ts'; +import { createLeafNodeSubmissionState } from '../lib/client-reactivity/submission/createLeafNodeSubmissionState.ts'; import { createSelectItems } from '../lib/reactivity/createSelectItems.ts'; import { createValueState } from '../lib/reactivity/createValueState.ts'; import type { CurrentState } from '../lib/reactivity/node-state/createCurrentState.ts'; @@ -25,6 +27,7 @@ import type { EvaluationContext } from './internal-api/EvaluationContext.ts'; import type { SubscribableDependency } from './internal-api/SubscribableDependency.ts'; import type { ValidationContext } from './internal-api/ValidationContext.ts'; import type { ValueContext } from './internal-api/ValueContext.ts'; +import type { ClientReactiveSubmittableLeafNode } from './internal-api/submission/ClientReactiveSubmittableLeafNode.ts'; export interface SelectFieldDefinition extends LeafNodeDefinition { readonly bodyElement: AnySelectDefinition; @@ -45,7 +48,8 @@ export class SelectField EvaluationContext, SubscribableDependency, ValidationContext, - ValueContext + ValueContext, + ClientReactiveSubmittableLeafNode { private readonly selectExclusive: boolean; private readonly validation: SharedValidationState; @@ -63,6 +67,8 @@ export class SelectField return this.validation.currentState; } + readonly submissionState: SubmissionState; + // ValueContext readonly encodeValue = (runtimeValue: readonly SelectItem[]): string => { const itemValues = new Set(runtimeValue.map(({ value }) => value)); @@ -125,6 +131,7 @@ export class SelectField this.engineState = state.engineState; this.currentState = state.currentState; this.validation = createValidationState(this, sharedStateOptions); + this.submissionState = createLeafNodeSubmissionState(this); } protected getSelectItemsByValue( diff --git a/packages/xforms-engine/src/instance/StringField.ts b/packages/xforms-engine/src/instance/StringField.ts index 1e035c4b0..af441b0af 100644 --- a/packages/xforms-engine/src/instance/StringField.ts +++ b/packages/xforms-engine/src/instance/StringField.ts @@ -2,7 +2,9 @@ import { identity } from '@getodk/common/lib/identity.ts'; import type { Accessor } from 'solid-js'; import type { StringNode, StringNodeAppearances } from '../client/StringNode.ts'; import type { TextRange } from '../client/TextRange.ts'; +import type { SubmissionState } from '../client/submission/SubmissionState.ts'; import type { AnyViolation, LeafNodeValidationState } from '../client/validation.ts'; +import { createLeafNodeSubmissionState } from '../lib/client-reactivity/submission/createLeafNodeSubmissionState.ts'; import { createValueState } from '../lib/reactivity/createValueState.ts'; import type { CurrentState } from '../lib/reactivity/node-state/createCurrentState.ts'; import type { EngineState } from '../lib/reactivity/node-state/createEngineState.ts'; @@ -23,6 +25,7 @@ import type { EvaluationContext } from './internal-api/EvaluationContext.ts'; import type { SubscribableDependency } from './internal-api/SubscribableDependency.ts'; import type { ValidationContext } from './internal-api/ValidationContext.ts'; import type { ValueContext } from './internal-api/ValueContext.ts'; +import type { ClientReactiveSubmittableLeafNode } from './internal-api/submission/ClientReactiveSubmittableLeafNode.ts'; export interface StringFieldDefinition extends LeafNodeDefinition { readonly bodyElement: InputDefinition; @@ -43,7 +46,8 @@ export class StringField EvaluationContext, SubscribableDependency, ValidationContext, - ValueContext + ValueContext, + ClientReactiveSubmittableLeafNode { private readonly validation: SharedValidationState; protected readonly state: SharedNodeState; @@ -60,6 +64,8 @@ export class StringField return this.validation.currentState; } + readonly submissionState: SubmissionState; + // ValueContext readonly encodeValue = identity; @@ -95,6 +101,7 @@ export class StringField this.engineState = state.engineState; this.currentState = state.currentState; this.validation = createValidationState(this, sharedStateOptions); + this.submissionState = createLeafNodeSubmissionState(this); } // ValidationContext diff --git a/packages/xforms-engine/src/instance/Subtree.ts b/packages/xforms-engine/src/instance/Subtree.ts index d65d373a0..3825853b6 100644 --- a/packages/xforms-engine/src/instance/Subtree.ts +++ b/packages/xforms-engine/src/instance/Subtree.ts @@ -1,7 +1,9 @@ import { type Accessor } from 'solid-js'; import type { FormNodeID } from '../client/identity.ts'; +import type { SubmissionState } from '../client/submission/SubmissionState.ts'; import type { SubtreeDefinition, SubtreeNode } from '../client/SubtreeNode.ts'; import type { AncestorNodeValidationState } from '../client/validation.ts'; +import { createParentNodeSubmissionState } from '../lib/client-reactivity/submission/createParentNodeSubmissionState.ts'; import type { ChildrenState } from '../lib/reactivity/createChildrenState.ts'; import { createChildrenState } from '../lib/reactivity/createChildrenState.ts'; import type { MaterializedChildren } from '../lib/reactivity/materializeCurrentStateChildren.ts'; @@ -16,6 +18,7 @@ import { DescendantNode } from './abstract/DescendantNode.ts'; import { buildChildren } from './children.ts'; import type { GeneralChildNode, GeneralParentNode } from './hierarchy.ts'; import type { EvaluationContext } from './internal-api/EvaluationContext.ts'; +import type { ClientReactiveSubmittableParentNode } from './internal-api/submission/ClientReactiveSubmittableParentNode.ts'; import type { SubscribableDependency } from './internal-api/SubscribableDependency.ts'; interface SubtreeStateSpec extends DescendantNodeSharedStateSpec { @@ -28,7 +31,11 @@ interface SubtreeStateSpec extends DescendantNodeSharedStateSpec { export class Subtree extends DescendantNode - implements SubtreeNode, EvaluationContext, SubscribableDependency + implements + SubtreeNode, + EvaluationContext, + SubscribableDependency, + ClientReactiveSubmittableParentNode { private readonly childrenState: ChildrenState; @@ -41,6 +48,7 @@ export class Subtree readonly appearances = null; readonly currentState: MaterializedChildren, GeneralChildNode>; readonly validationState: AncestorNodeValidationState; + readonly submissionState: SubmissionState; constructor(parent: GeneralParentNode, definition: SubtreeDefinition) { super(parent, definition); @@ -80,6 +88,7 @@ export class Subtree childrenState.setChildren(buildChildren(this)); this.validationState = createAggregatedViolations(this, sharedStateOptions); + this.submissionState = createParentNodeSubmissionState(this); } getChildren(): readonly GeneralChildNode[] { diff --git a/packages/xforms-engine/src/instance/TriggerControl.ts b/packages/xforms-engine/src/instance/TriggerControl.ts index 2e82d83fa..805e7e6d7 100644 --- a/packages/xforms-engine/src/instance/TriggerControl.ts +++ b/packages/xforms-engine/src/instance/TriggerControl.ts @@ -1,7 +1,9 @@ import type { Accessor } from 'solid-js'; import type { TextRange } from '../client/TextRange.ts'; import type { TriggerNode, TriggerNodeDefinition } from '../client/TriggerNode.ts'; +import type { SubmissionState } from '../client/submission/SubmissionState.ts'; import type { AnyViolation, LeafNodeValidationState } from '../client/validation.ts'; +import { createLeafNodeSubmissionState } from '../lib/client-reactivity/submission/createLeafNodeSubmissionState.ts'; import { createValueState } from '../lib/reactivity/createValueState.ts'; import type { CurrentState } from '../lib/reactivity/node-state/createCurrentState.ts'; import type { EngineState } from '../lib/reactivity/node-state/createEngineState.ts'; @@ -21,6 +23,7 @@ import type { EvaluationContext } from './internal-api/EvaluationContext.ts'; import type { SubscribableDependency } from './internal-api/SubscribableDependency.ts'; import type { ValidationContext } from './internal-api/ValidationContext.ts'; import type { ValueContext } from './internal-api/ValueContext.ts'; +import type { ClientReactiveSubmittableLeafNode } from './internal-api/submission/ClientReactiveSubmittableLeafNode.ts'; interface TriggerControlStateSpec extends DescendantNodeStateSpec { readonly label: Accessor | null>; @@ -39,7 +42,8 @@ export class TriggerControl EvaluationContext, SubscribableDependency, ValidationContext, - ValueContext + ValueContext, + ClientReactiveSubmittableLeafNode { private readonly validation: SharedValidationState; protected readonly state: SharedNodeState; @@ -56,6 +60,8 @@ export class TriggerControl return this.validation.currentState; } + readonly submissionState: SubmissionState; + // ValueContext readonly encodeValue: (runtimeValue: boolean) => string; readonly decodeValue: (instanceValue: string) => boolean; @@ -109,6 +115,7 @@ export class TriggerControl this.engineState = state.engineState; this.currentState = state.currentState; this.validation = createValidationState(this, sharedStateOptions); + this.submissionState = createLeafNodeSubmissionState(this); } // ValidationContext diff --git a/packages/xforms-engine/src/instance/abstract/InstanceNode.ts b/packages/xforms-engine/src/instance/abstract/InstanceNode.ts index 76e3e2f43..cb35ea1d1 100644 --- a/packages/xforms-engine/src/instance/abstract/InstanceNode.ts +++ b/packages/xforms-engine/src/instance/abstract/InstanceNode.ts @@ -4,6 +4,7 @@ import type { BaseNode } from '../../client/BaseNode.ts'; import type { NodeAppearances } from '../../client/NodeAppearances.ts'; import type { FormNodeID } from '../../client/identity.ts'; import type { InstanceNodeType } from '../../client/node-types.ts'; +import type { SubmissionState } from '../../client/submission/SubmissionState.ts'; import type { NodeValidationState } from '../../client/validation.ts'; import type { TextRange } from '../../index.ts'; import type { MaterializedChildren } from '../../lib/reactivity/materializeCurrentStateChildren.ts'; @@ -120,6 +121,8 @@ export abstract class InstanceNode< abstract readonly validationState: NodeValidationState; + abstract readonly submissionState: SubmissionState; + // BaseNode: structural abstract readonly root: Root; diff --git a/packages/xforms-engine/src/instance/abstract/UnsupportedControl.ts b/packages/xforms-engine/src/instance/abstract/UnsupportedControl.ts index 9ebfc0188..399b7e9f1 100644 --- a/packages/xforms-engine/src/instance/abstract/UnsupportedControl.ts +++ b/packages/xforms-engine/src/instance/abstract/UnsupportedControl.ts @@ -1,6 +1,7 @@ import { identity } from '@getodk/common/lib/identity.ts'; import type { Accessor } from 'solid-js'; import type { UnsupportedControlNodeType } from '../../client/node-types.ts'; +import type { SubmissionState } from '../../client/submission/SubmissionState.ts'; import type { TextRange } from '../../client/TextRange.ts'; import type { UnsupportedControlDefinition, @@ -8,6 +9,7 @@ import type { UnsupportedControlNode, } from '../../client/unsupported/UnsupportedControlNode.ts'; import type { AnyViolation, LeafNodeValidationState } from '../../client/validation.ts'; +import { createLeafNodeSubmissionState } from '../../lib/client-reactivity/submission/createLeafNodeSubmissionState.ts'; import { createValueState } from '../../lib/reactivity/createValueState.ts'; import type { CurrentState } from '../../lib/reactivity/node-state/createCurrentState.ts'; import type { EngineState } from '../../lib/reactivity/node-state/createEngineState.ts'; @@ -25,6 +27,7 @@ import { import type { UnknownAppearanceDefinition } from '../../parse/body/appearance/unknownAppearanceParser.ts'; import type { GeneralParentNode } from '../hierarchy.ts'; import type { EvaluationContext } from '../internal-api/EvaluationContext.ts'; +import type { ClientReactiveSubmittableLeafNode } from '../internal-api/submission/ClientReactiveSubmittableLeafNode.ts'; import type { SubscribableDependency } from '../internal-api/SubscribableDependency.ts'; import type { ValidationContext } from '../internal-api/ValidationContext.ts'; import type { ValueContext } from '../internal-api/ValueContext.ts'; @@ -67,7 +70,8 @@ export abstract class UnsupportedControl + ValueContext, + ClientReactiveSubmittableLeafNode { private readonly validation: SharedValidationState; protected readonly state: SharedNodeState; @@ -85,6 +89,8 @@ export abstract class UnsupportedControl { const encoded = instanceValue; @@ -128,6 +134,7 @@ export abstract class UnsupportedControl { + get relevant(): boolean; + get value(): RuntimeValue; +} + +export type SerializedSubmissionValue = string; + +interface ClientReactiveSubmittableLeafNodeDefinition { + readonly nodeName: string; +} + +export interface ClientReactiveSubmittableLeafNode { + readonly definition: ClientReactiveSubmittableLeafNodeDefinition; + readonly currentState: ClientReactiveSubmittableLeafNodeCurrentState; + + /** + * A client-reactive submittable leaf node is responsible for producing a + * string representation of its value state, suitable for serialization for + * submission. It **MUST NOT** perform any further submission-specific + * serialization duties: in particular, the value **MUST NOT** be escaped for + * XML. This responsibility is delegated up the stack, to avoid repeat + * escaping. + * + * Note: excluding {@link EscapedXMLText} here does not have an effect on the + * type system, it is a documentation-only hint, to help guard against future + * double-escaping mistakes. + */ + readonly encodeValue: ( + this: unknown, + runtimeValue: RuntimeValue + ) => Exclude; + + readonly submissionState: SubmissionState; +} diff --git a/packages/xforms-engine/src/instance/internal-api/submission/ClientReactiveSubmittableParentNode.ts b/packages/xforms-engine/src/instance/internal-api/submission/ClientReactiveSubmittableParentNode.ts new file mode 100644 index 000000000..3c2656120 --- /dev/null +++ b/packages/xforms-engine/src/instance/internal-api/submission/ClientReactiveSubmittableParentNode.ts @@ -0,0 +1,24 @@ +import type { SubmissionState } from '../../../client/submission/SubmissionState.ts'; + +export interface ClientReactiveSubmittableChildNode { + readonly submissionState: SubmissionState; +} + +interface ClientReactiveSubmittableParentNodeCurrentState< + Child extends ClientReactiveSubmittableChildNode, +> { + get relevant(): boolean; + get children(): readonly Child[]; +} + +interface ClientReactiveSubmittableParentNodeDefinition { + readonly nodeName: string; +} + +export interface ClientReactiveSubmittableParentNode< + Child extends ClientReactiveSubmittableChildNode, +> { + readonly definition: ClientReactiveSubmittableParentNodeDefinition; + readonly currentState: ClientReactiveSubmittableParentNodeCurrentState; + readonly submissionState: SubmissionState; +} diff --git a/packages/xforms-engine/src/instance/repeat/BaseRepeatRange.ts b/packages/xforms-engine/src/instance/repeat/BaseRepeatRange.ts index 1000480da..cb5ae9c16 100644 --- a/packages/xforms-engine/src/instance/repeat/BaseRepeatRange.ts +++ b/packages/xforms-engine/src/instance/repeat/BaseRepeatRange.ts @@ -3,8 +3,10 @@ import { untrack, type Accessor } from 'solid-js'; import type { FormNodeID } from '../../client/identity.ts'; import type { NodeAppearances } from '../../client/NodeAppearances.ts'; import type { BaseRepeatRangeNode } from '../../client/repeat/BaseRepeatRangeNode.ts'; +import type { SubmissionState } from '../../client/submission/SubmissionState.ts'; import type { TextRange } from '../../client/TextRange.ts'; import type { AncestorNodeValidationState } from '../../client/validation.ts'; +import { createNodeRangeSubmissionState } from '../../lib/client-reactivity/submission/createNodeRangeSubmissionState.ts'; import type { ChildrenState } from '../../lib/reactivity/createChildrenState.ts'; import { createChildrenState } from '../../lib/reactivity/createChildrenState.ts'; import { createComputedExpression } from '../../lib/reactivity/createComputedExpression.ts'; @@ -27,6 +29,7 @@ import type { import { DescendantNode } from '../abstract/DescendantNode.ts'; import type { RepeatRange } from '../hierarchy.ts'; import type { EvaluationContext } from '../internal-api/EvaluationContext.ts'; +import type { ClientReactiveSubmittableParentNode } from '../internal-api/submission/ClientReactiveSubmittableParentNode.ts'; import type { SubscribableDependency } from '../internal-api/SubscribableDependency.ts'; import { RepeatInstance, type RepeatDefinition } from './RepeatInstance.ts'; @@ -46,7 +49,11 @@ type BaseRepeatRangeNodeType = export abstract class BaseRepeatRange extends DescendantNode - implements BaseRepeatRangeNode, EvaluationContext, SubscribableDependency + implements + BaseRepeatRangeNode, + EvaluationContext, + SubscribableDependency, + ClientReactiveSubmittableParentNode { /** * A repeat range doesn't have a corresponding primary instance element of its @@ -193,6 +200,8 @@ export abstract class BaseRepeatRange, definition: Definition) { super(parent, definition); @@ -252,6 +261,7 @@ export abstract class BaseRepeatRange - implements RepeatInstanceNode, EvaluationContext, SubscribableDependency + implements + RepeatInstanceNode, + EvaluationContext, + SubscribableDependency, + ClientReactiveSubmittableParentNode { private readonly childrenState: ChildrenState; private readonly currentIndex: Accessor; @@ -89,6 +96,7 @@ export class RepeatInstance GeneralChildNode >; readonly validationState: AncestorNodeValidationState; + readonly submissionState: SubmissionState; constructor( override readonly parent: RepeatRange, @@ -171,6 +179,7 @@ export class RepeatInstance childrenState.setChildren(buildChildren(this)); this.validationState = createAggregatedViolations(this, sharedStateOptions); + this.submissionState = createParentNodeSubmissionState(this); } protected override initializeContextNode(parentContextNode: Element, nodeName: string): Element { diff --git a/packages/xforms-engine/src/lib/client-reactivity/README.md b/packages/xforms-engine/src/lib/client-reactivity/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/packages/xforms-engine/src/lib/client-reactivity/submission/createLeafNodeSubmissionState.ts b/packages/xforms-engine/src/lib/client-reactivity/submission/createLeafNodeSubmissionState.ts new file mode 100644 index 000000000..13c1cba34 --- /dev/null +++ b/packages/xforms-engine/src/lib/client-reactivity/submission/createLeafNodeSubmissionState.ts @@ -0,0 +1,20 @@ +import type { SubmissionState } from '../../../client/submission/SubmissionState.ts'; +import type { ClientReactiveSubmittableLeafNode } from '../../../instance/internal-api/submission/ClientReactiveSubmittableLeafNode.ts'; +import { escapeXMLText, serializeLeafElementXML } from '../../xml-serialization.ts'; + +export const createLeafNodeSubmissionState = ( + node: ClientReactiveSubmittableLeafNode +): SubmissionState => { + return { + get submissionXML() { + if (!node.currentState.relevant) { + return ''; + } + + const value = node.encodeValue(node.currentState.value); + const xmlValue = escapeXMLText(value); + + return serializeLeafElementXML(node.definition.nodeName, xmlValue); + }, + }; +}; diff --git a/packages/xforms-engine/src/lib/client-reactivity/submission/createNodeRangeSubmissionState.ts b/packages/xforms-engine/src/lib/client-reactivity/submission/createNodeRangeSubmissionState.ts new file mode 100644 index 000000000..a460d6d35 --- /dev/null +++ b/packages/xforms-engine/src/lib/client-reactivity/submission/createNodeRangeSubmissionState.ts @@ -0,0 +1,17 @@ +import type { SubmissionState } from '../../../client/submission/SubmissionState.ts'; +import type { ClientReactiveSubmittableParentNode } from '../../../instance/internal-api/submission/ClientReactiveSubmittableParentNode.ts'; +import type { RepeatInstance } from '../../../instance/repeat/RepeatInstance.ts'; + +export const createNodeRangeSubmissionState = ( + node: ClientReactiveSubmittableParentNode +): SubmissionState => { + return { + get submissionXML() { + const serializedChildren = node.currentState.children.map((child) => { + return child.submissionState.submissionXML; + }); + + return serializedChildren.join(''); + }, + }; +}; diff --git a/packages/xforms-engine/src/lib/client-reactivity/submission/createParentNodeSubmissionState.ts b/packages/xforms-engine/src/lib/client-reactivity/submission/createParentNodeSubmissionState.ts new file mode 100644 index 000000000..249f4f208 --- /dev/null +++ b/packages/xforms-engine/src/lib/client-reactivity/submission/createParentNodeSubmissionState.ts @@ -0,0 +1,22 @@ +import type { SubmissionState } from '../../../client/submission/SubmissionState.ts'; +import type { GeneralChildNode } from '../../../instance/hierarchy.ts'; +import type { ClientReactiveSubmittableParentNode } from '../../../instance/internal-api/submission/ClientReactiveSubmittableParentNode.ts'; +import { serializeParentElementXML } from '../../xml-serialization.ts'; + +export const createParentNodeSubmissionState = ( + node: ClientReactiveSubmittableParentNode +): SubmissionState => { + return { + get submissionXML() { + if (!node.currentState.relevant) { + return ''; + } + + const serializedChildren = node.currentState.children.map((child) => { + return child.submissionState.submissionXML; + }); + + return serializeParentElementXML(node.definition.nodeName, serializedChildren); + }, + }; +}; diff --git a/packages/xforms-engine/src/lib/xml-serialization.ts b/packages/xforms-engine/src/lib/xml-serialization.ts index bf8e64501..a21b261f3 100644 --- a/packages/xforms-engine/src/lib/xml-serialization.ts +++ b/packages/xforms-engine/src/lib/xml-serialization.ts @@ -1,6 +1,6 @@ declare const ESCAPED_XML_TEXT_BRAND: unique symbol; -type EscapedXMLText = string & { readonly [ESCAPED_XML_TEXT_BRAND]: true }; +export type EscapedXMLText = string & { readonly [ESCAPED_XML_TEXT_BRAND]: true }; const ATTR_REGEX = /[&<>"]/; const CONTENT_REGEX = /[&<>]/; @@ -74,3 +74,23 @@ export const escapeXMLText = ( ? ((out + text.substring(lastIndex, index)) as EscapedXMLText) : (out as EscapedXMLText); }; + +const serializeElementXML = (nodeName: string, children: string): string => { + if (children === '') { + return `<${nodeName}/>`; + } + + // TODO: attributes + return `<${nodeName}>${children}`; +}; + +export const serializeParentElementXML = ( + nodeName: string, + serializedChildren: readonly string[] +): string => { + return serializeElementXML(nodeName, serializedChildren.join('')); +}; + +export const serializeLeafElementXML = (nodeName: string, xmlValue: EscapedXMLText): string => { + return serializeElementXML(nodeName, xmlValue); +}; From 6e84a47d775eaa9833b7584b19df24d315b763c4 Mon Sep 17 00:00:00 2001 From: eyelidlessness Date: Mon, 23 Sep 2024 14:58:03 -0700 Subject: [PATCH 06/18] ui-solid: incorporate submission XML serialization This drops the custom serialization previously used as a part of the ui-solid demo. (The functionality was also partially broken: it serialized repeat ranges as elements. This helped guide testing in the prior commit.) --- .../src/components/XForm/XFormDetails.tsx | 70 +------------------ 1 file changed, 3 insertions(+), 67 deletions(-) diff --git a/packages/ui-solid/src/components/XForm/XFormDetails.tsx b/packages/ui-solid/src/components/XForm/XFormDetails.tsx index 800a36e10..b7d140c45 100644 --- a/packages/ui-solid/src/components/XForm/XFormDetails.tsx +++ b/packages/ui-solid/src/components/XForm/XFormDetails.tsx @@ -1,6 +1,6 @@ -import type { AnyNode, RootNode } from '@getodk/xforms-engine'; +import type { RootNode } from '@getodk/xforms-engine'; import { styled } from '@suid/material'; -import { Show, createMemo, createSignal } from 'solid-js'; +import { Show, createSignal } from 'solid-js'; const Details = styled('details')({ position: 'relative', @@ -24,66 +24,6 @@ export interface XFormDetailsProps { readonly root: RootNode; } -let xmlEscaper: Element | null = null; - -const getEscaper = (): Element => { - xmlEscaper = xmlEscaper ?? document.createElement('esc-aper'); - - return xmlEscaper; -}; - -const escapeXMLText = (value: string) => { - const escaper = getEscaper(); - - escaper.textContent = value; - - const { innerHTML } = escaper; - - escaper.textContent = ''; - - return innerHTML; -}; - -type FakeSerializationInterface = AnyNode & { - readonly contextNode: { - readonly textContent: string | null; - }; -}; - -const indentLine = (depth: number, line: string) => { - const indentation = ''.padStart(depth, ' '); - - return `${indentation}${line}`; -}; - -const serializeNode = (node: AnyNode, depth = 0): string => { - node = node as FakeSerializationInterface; - - const { currentState, definition } = node; - const { children } = currentState; - const { nodeName } = definition; - - if (children == null) { - // Just read it to make it reactive... - // eslint-disable-next-line @typescript-eslint/no-unused-expressions -- read == subscribe - currentState.value; - - const serializedLeafNode = `<${nodeName}>${escapeXMLText((node as FakeSerializationInterface).contextNode.textContent ?? '')}`; - - return indentLine(depth, serializedLeafNode); - } - - return [ - indentLine(depth, `<${nodeName}>`), - children.map((child) => { - return serializeNode(child, depth + 1); - }), - indentLine(depth, ``), - ] - .flat() - .join('\n'); -}; - export const XFormDetails = (props: XFormDetailsProps) => { const [showSubmissionState, setShowSubmissionState] = createSignal(false); @@ -97,11 +37,7 @@ export const XFormDetails = (props: XFormDetailsProps) => { Submission state (XML) {(_) => { - const submissionState = createMemo(() => { - return serializeNode(props.root); - }); - - return
{submissionState()}
; + return
{props.root.submissionState.submissionXML}
; }}
From dfd7ae309a281b28ad1c8d9f1c294d1e563ef9d3 Mon Sep 17 00:00:00 2001 From: eyelidlessness Date: Wed, 25 Sep 2024 09:45:00 -0700 Subject: [PATCH 07/18] scenario: ComparableAssertableValue (which ComparableAnswer now extends) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This just adds a new layer of inheritance for the same `Comparable*` base functionality, allowing for non-“answer” values to use the same comparison/assertion extension facilities. --- .../scenario/src/answer/ComparableAnswer.ts | 42 +------------ .../comparable/ComparableAssertableValue.ts | 60 +++++++++++++++++++ 2 files changed, 62 insertions(+), 40 deletions(-) create mode 100644 packages/scenario/src/comparable/ComparableAssertableValue.ts diff --git a/packages/scenario/src/answer/ComparableAnswer.ts b/packages/scenario/src/answer/ComparableAnswer.ts index 9c1d8e2d6..c80756b4a 100644 --- a/packages/scenario/src/answer/ComparableAnswer.ts +++ b/packages/scenario/src/answer/ComparableAnswer.ts @@ -1,13 +1,6 @@ -import type { SimpleAssertionResult } from '@getodk/common/test/assertions/vitest/shared-extension-types.ts'; -import type { JSONValue } from '@getodk/common/types/JSONValue.ts'; +import { ComparableAssertableValue } from '../comparable/ComparableAssertableValue.ts'; import type { Scenario } from '../jr/Scenario.ts'; -interface OptionalBooleanComparable { - // Expressed here so it can be overridden as either a `readonly` property or - // as a `get` accessor - readonly booleanValue?: boolean; -} - /** * Provides a common interface for comparing "answer" values of arbitrary data * types, where the answer may be obtained from: @@ -21,36 +14,5 @@ interface OptionalBooleanComparable { * {@link https://vitest.dev/guide/extending-matchers.html | extended} * assertions/matchers. */ -// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging -- see OptionalBooleanComparable.booleanValue -export abstract class ComparableAnswer implements OptionalBooleanComparable { - abstract get stringValue(): string; - - // To be overridden - equals( - // @ts-expect-error -- part of the interface to be overridden - // eslint-disable-next-line @typescript-eslint/no-unused-vars - answer: ComparableAnswer - ): SimpleAssertionResult | null { - return null; - } - - /** - * Note: we currently return {@link stringValue} here, but this probably - * won't last as we expand support for other data types. This is why the - * return type is currently `unknown`. - */ - getValue(): unknown { - return this.stringValue; - } - - inspectValue(): JSONValue { - return this.stringValue; - } - - toString(): string { - return this.stringValue; - } -} -// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging -- see OptionalBooleanComparable.booleanValue -export interface ComparableAnswer extends OptionalBooleanComparable {} +export abstract class ComparableAnswer extends ComparableAssertableValue {} diff --git a/packages/scenario/src/comparable/ComparableAssertableValue.ts b/packages/scenario/src/comparable/ComparableAssertableValue.ts new file mode 100644 index 000000000..7fdf85d37 --- /dev/null +++ b/packages/scenario/src/comparable/ComparableAssertableValue.ts @@ -0,0 +1,60 @@ +import type { SimpleAssertionResult } from '@getodk/common/test/assertions/vitest/shared-extension-types.ts'; +import type { JSONValue } from '@getodk/common/types/JSONValue.ts'; +import type { Scenario } from '../jr/Scenario.ts'; + +interface OptionalBooleanComparable { + // Expressed here so it can be overridden as either a `readonly` property or + // as a `get` accessor + readonly booleanValue?: boolean; +} + +/** + * Provides a common interface for comparing values of arbitrary data types, to + * support specialized comparison logic between values produced by + * {@link Scenario} and the expected values asserted against those. + * + * Example use cases include asserting expected values for: + * + * - "Answers" (the {@link Scenario} concept, as read from "questions") + * - Serialized XML (where we may elide certain formatting differences, such as + * length of whitespace, or whether an empty element is self-closed) + * + * This interface is used to support evaluation of assertions, where their + * JavaRosa expression has been adapted to the closest equivalent in Vitest's + * {@link https://vitest.dev/api/expect.html | built-in} or + * {@link https://vitest.dev/guide/extending-matchers.html | extended} + * assertions/matchers. + */ +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging -- see OptionalBooleanComparable.booleanValue +export abstract class ComparableAssertableValue implements OptionalBooleanComparable { + abstract get stringValue(): string; + + // To be overridden + equals( + // @ts-expect-error -- part of the interface to be overridden + // eslint-disable-next-line @typescript-eslint/no-unused-vars + other: ComparableAssertableValue + ): SimpleAssertionResult | null { + return null; + } + + /** + * Note: we currently return {@link stringValue} here, but this probably + * won't last as we expand support for other data types. This is why the + * return type is currently `unknown`. + */ + getValue(): unknown { + return this.stringValue; + } + + inspectValue(): JSONValue { + return this.stringValue; + } + + toString(): string { + return this.stringValue; + } +} + +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging -- see OptionalBooleanComparable.booleanValue +export interface ComparableAssertableValue extends OptionalBooleanComparable {} From ca9c779a10493424218aa09e03e0caa6fa35a9c8 Mon Sep 17 00:00:00 2001 From: eyelidlessness Date: Fri, 27 Sep 2024 11:56:23 -0700 Subject: [PATCH 08/18] scenario: integrate submission XML serialization support for testing --- packages/scenario/src/jr/Scenario.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/scenario/src/jr/Scenario.ts b/packages/scenario/src/jr/Scenario.ts index abc65ca05..3b8b37c3f 100644 --- a/packages/scenario/src/jr/Scenario.ts +++ b/packages/scenario/src/jr/Scenario.ts @@ -901,7 +901,7 @@ export class Scenario { } proposed_serializeInstance(): string { - throw new ImplementationPendingError('instance serialization'); + return this.instanceRoot.submissionState.submissionXML; } // TODO: consider adapting tests which use the following interfaces to use From e856c9f56721b23268be537c808624719d80b3e0 Mon Sep 17 00:00:00 2001 From: eyelidlessness Date: Fri, 27 Sep 2024 11:56:36 -0700 Subject: [PATCH 09/18] scenario: update existing submission XML serialization test, now passing --- packages/scenario/test/submission.test.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/scenario/test/submission.test.ts b/packages/scenario/test/submission.test.ts index 84aa67f94..019a2d12e 100644 --- a/packages/scenario/test/submission.test.ts +++ b/packages/scenario/test/submission.test.ts @@ -17,18 +17,12 @@ describe('Form submission', () => { /** * **PORTING NOTES** * - * - JavaRosa provides this method on an `XFormSerializingVisitor` class. We - * add a proposed API equivalent on {@link Scenario}. (Direct ported code - * is preserved, commented out above the proposed API usage.) - * - * - Test currently fails pending feature support. - * - * - This test is valuable, but we should expand the suite to cover at least - * general serialization, as well as any other potential edge cases we - * might anticipate. + * JavaRosa provides this method on an `XFormSerializingVisitor` class. We + * add a proposed API equivalent on {@link Scenario}. (Direct ported code is + * preserved, commented out above the proposed API usage.) */ describe('`serializeInstance`', () => { - it.fails('preserves unicode characters', async () => { + it('preserves unicode characters', async () => { const formDef = html( head( title('Some form'), From 4435cbd0e4a6d090021c88bb410a120de7ce3800 Mon Sep 17 00:00:00 2001 From: eyelidlessness Date: Fri, 27 Sep 2024 11:59:37 -0700 Subject: [PATCH 10/18] scenario: expand submission test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Includes custom assertions for XML comparison. This is also intended to support: - re-using the XForms DSL to assert expected serializations - comparing equivalent XML structures which may have slightly different formatting Tests serialization of: - default values - runtime-assigned values - repeats (ranges produce their children) - non-relevant nodes (i.e. that they are omitted) - normalized unicode (see comment for rationale) A subsequent commit may expand the suite further to exercise the client-reactive aspects of the engine’s submission serialization API. --- .../src/assertion/extensions/submission.ts | 37 +++ packages/scenario/src/assertion/setup.ts | 1 + .../ComparableXMLSerialization.ts | 179 ++++++++++ packages/scenario/test/submission.test.ts | 308 +++++++++++++++++- .../src/lib/xml-serialization.ts | 2 +- 5 files changed, 525 insertions(+), 2 deletions(-) create mode 100644 packages/scenario/src/assertion/extensions/submission.ts create mode 100644 packages/scenario/src/serialization/ComparableXMLSerialization.ts diff --git a/packages/scenario/src/assertion/extensions/submission.ts b/packages/scenario/src/assertion/extensions/submission.ts new file mode 100644 index 000000000..241b80d4e --- /dev/null +++ b/packages/scenario/src/assertion/extensions/submission.ts @@ -0,0 +1,37 @@ +import type { DeriveStaticVitestExpectExtension } from '@getodk/common/test/assertions/helpers.ts'; +import { + AsymmetricTypedExpectExtension, + extendExpect, +} from '@getodk/common/test/assertions/helpers.ts'; +import { assert, expect } from 'vitest'; +import { Scenario } from '../../jr/Scenario.ts'; +import { ComparableXMLSerialization } from '../../serialization/ComparableXMLSerialization.ts'; +import { assertString } from './shared-type-assertions.ts'; + +type AssertScenario = (value: unknown) => asserts value is Scenario; + +const assertScenario: AssertScenario = (value) => { + assert(value instanceof Scenario); +}; + +export const submissionExtensions = extendExpect(expect, { + toHaveSerializedSubmissionXML: new AsymmetricTypedExpectExtension( + assertScenario, + assertString, + (actual, expected) => { + const comparableActual = new ComparableXMLSerialization(actual.proposed_serializeInstance()); + const comparableExpected = new ComparableXMLSerialization(expected); + + return comparableActual.equals(comparableExpected); + } + ), +}); + +type SubmissionExtensions = typeof submissionExtensions; + +declare module 'vitest' { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + interface Assertion extends DeriveStaticVitestExpectExtension {} + interface AsymmetricMatchersContaining + extends DeriveStaticVitestExpectExtension {} +} diff --git a/packages/scenario/src/assertion/setup.ts b/packages/scenario/src/assertion/setup.ts index 2c4e83d69..2178feea8 100644 --- a/packages/scenario/src/assertion/setup.ts +++ b/packages/scenario/src/assertion/setup.ts @@ -4,4 +4,5 @@ import './extensions/body-classes.ts'; import './extensions/choices.ts'; import './extensions/form-state.ts'; import './extensions/node-state.ts'; +import './extensions/submission.ts'; import './extensions/tree-reference.ts'; diff --git a/packages/scenario/src/serialization/ComparableXMLSerialization.ts b/packages/scenario/src/serialization/ComparableXMLSerialization.ts new file mode 100644 index 000000000..e61719ad7 --- /dev/null +++ b/packages/scenario/src/serialization/ComparableXMLSerialization.ts @@ -0,0 +1,179 @@ +import { XFORMS_NAMESPACE_URI } from '@getodk/common/constants/xmlns.ts'; +import { InspectableComparisonError } from '@getodk/common/test/assertions/helpers.ts'; +import type { SimpleAssertionResult } from '@getodk/common/test/assertions/vitest/shared-extension-types.ts'; +import { ComparableAssertableValue } from '../comparable/ComparableAssertableValue.ts'; + +class ComparableXMLQualifiedName { + readonly sortKey: string; + + constructor( + readonly namespaceURI: string | null, + readonly localName: string + ) { + this.sortKey = JSON.stringify({ namespaceURI, localName }); + } + + /** + * @todo prefix re-serialization + */ + toString(): string { + const { namespaceURI } = this; + + if (namespaceURI == null || namespaceURI === XFORMS_NAMESPACE_URI) { + return this.localName; + } + + return this.sortKey; + } +} + +class ComparableXMLAttribute { + static from(attr: Attr): ComparableXMLAttribute { + return new this(attr.namespaceURI, attr.localName, attr.value); + } + + readonly qualifiedName: ComparableXMLQualifiedName; + + private constructor( + namespaceURI: string | null, + localName: string, + readonly value: string + ) { + this.qualifiedName = new ComparableXMLQualifiedName(namespaceURI, localName); + } + + /** + * Note: re-serialization is space prefixed for easier use downstream (i.e. + * re-serialization of the element an attribute belongs to). + * + * @todo value re-escaping. Probably means moving XML escaping up to common + * after all? + */ + toString(): string { + return ` ${this.qualifiedName.toString()}="${this.value}"`; + } +} + +const comparableXMLElementAttributes = (element: Element): readonly ComparableXMLAttribute[] => { + const attributes = Array.from(element.attributes).map((attr) => { + return ComparableXMLAttribute.from(attr); + }); + + return attributes.sort(({ qualifiedName: a }, { qualifiedName: b }) => { + if (a > b) { + return 1; + } + + if (b > a) { + return -1; + } + + return 0; + }); +}; + +const isElement = (node: ChildNode): node is Element => { + return node.nodeType === Node.ELEMENT_NODE; +}; + +const isText = (node: ChildNode): node is CDATASection | Text => { + return node.nodeType === Node.CDATA_SECTION_NODE || node.nodeType === Node.TEXT_NODE; +}; + +/** + * @todo we will probably also need to support comments (e.g. if/when we leave + * markers for non-relevant repeat sub-ranges). + */ +type ComparableXMLElementChild = ComparableXMLElement | string; + +const comparableXMLElementChildren = (node: Element): readonly ComparableXMLElementChild[] => { + const clone = node.cloneNode(true); + + clone.normalize(); + + return Array.from(clone.childNodes).flatMap((child) => { + if (isElement(child)) { + return ComparableXMLElement.from(child); + } + + if (isText(child)) { + // TODO: collapse whitespace + return child.data; + } + + // TODO: more detail + throw new Error('Unexpected node'); + }); +}; + +class ComparableXMLElement { + static fromXML(xml: string): ComparableXMLElement { + const domParser = new DOMParser(); + const xmlDocument = domParser.parseFromString(xml, 'text/xml'); + + return this.from(xmlDocument.documentElement); + } + + static from(element: Element): ComparableXMLElement { + const attributes = comparableXMLElementAttributes(element); + const children = comparableXMLElementChildren(element); + + return new this(element.namespaceURI, element.localName, attributes, children); + } + + readonly qualifiedName: ComparableXMLQualifiedName; + + private constructor( + namespaceURI: string | null, + localName: string, + readonly attributes: readonly ComparableXMLAttribute[], + readonly children: readonly ComparableXMLElementChild[] + ) { + this.qualifiedName = new ComparableXMLQualifiedName(namespaceURI, localName); + } + + toString(): string { + const attributes = this.attributes.map((attribute) => attribute.toString()).join(''); + const children = this.children.map((child) => child.toString()).join(''); + const nodeName = this.qualifiedName.toString(); + const prefix = `<${nodeName}${attributes}`; + + if (children === '') { + return `${prefix}/>`; + } + + return `${prefix}>${children}`; + } +} + +export class ComparableXMLSerialization extends ComparableAssertableValue { + private _element: ComparableXMLElement | null = null; + + get element(): ComparableXMLElement { + if (this._element == null) { + this._element = ComparableXMLElement.fromXML(this.xml); + } + + return this._element; + } + + get stringValue(): string { + return this.element.toString(); + } + + override equals(other: ComparableAssertableValue): SimpleAssertionResult { + let pass: boolean; + + if (other instanceof ComparableXMLSerialization && this.xml === other.xml) { + pass = true; + } else { + pass = this.stringValue === other.stringValue; + } + + return pass || new InspectableComparisonError(this, other, 'equal'); + } + + constructor(readonly xml: string) { + super(); + } +} diff --git a/packages/scenario/test/submission.test.ts b/packages/scenario/test/submission.test.ts index 019a2d12e..38737c93b 100644 --- a/packages/scenario/test/submission.test.ts +++ b/packages/scenario/test/submission.test.ts @@ -1,15 +1,20 @@ import { bind, body, + group, head, html, input, + item, + label, mainInstance, model, + repeat, + select1, t, title, } from '@getodk/common/test/fixtures/xform-dsl/index.ts'; -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { Scenario } from '../src/jr/Scenario.ts'; describe('Form submission', () => { @@ -45,4 +50,305 @@ describe('Form submission', () => { }); }); }); + + describe('instance serialization', () => { + const DEFAULT_INSTANCE_ID = 'uuid:TODO-mock-xpath-functions'; + + type TriggerValue = '' | 'OK'; + + interface InstanceDefaultValues { + readonly inp?: string; + readonly sel1?: string; + readonly selN?: string; + readonly not?: string; + readonly trig?: TriggerValue; + readonly modelVal?: string; + } + + it.each([ + {}, + { inp: 'input default' }, + { sel1: 'a' }, + { sel1: 'b' }, + { selN: 'one' }, + { selN: 'one two' }, + { not: 'note default' }, + { trig: 'OK' }, + { modelVal: 'modelVal default' }, + { + inp: 'input default', + sel1: 'b', + selN: 'one two', + not: 'note default', + trig: 'OK', + modelVal: 'modelVal default', + }, + ])('serializes default values %j', async (defaults) => { + // prettier-ignore + const scenario = await Scenario.init('XML serialization - basic, default values', html( + head( + title('XML serialization - basic, default values'), + model( + mainInstance( + t('data id="xml-serialization-basic-default-values"', + t('grp', + t('inp', defaults.inp ?? ''), + t('sel1', defaults.sel1 ?? ''), + t('selN', defaults.selN ?? '')), + t('not', defaults.not ?? ''), + t('trig', defaults.trig ?? ''), + t('subt', + t('modelVal', defaults.modelVal ?? '')), + t('calc'), + t('meta', + t('instanceID'))) + ), + bind('/data/grp/inp').type('string'), + bind('/data/grp/sel1').type('string'), + bind('/data/grp/selN').type('string'), + bind('/data/not').type('string').readonly(), + bind('/data/trig').type('string'), + bind('/data/subt/modelVal').type('string'), + bind('/data/calc').calculate('1 + 2'), + bind('/data/meta/instanceID').calculate(`'${DEFAULT_INSTANCE_ID}'`) + ) + ), + body( + group('/data/grp', + label('grp (group)'), + input('/data/grp/inp', + label('inp (group / input)')), + select1('/data/grp/sel1', + label('sel1 (group / select1)'), + item('a', 'A'), + item('b', 'B') + ), + t('select ref="/data/grp/selN"', + label('selN (group / select)'), + item('one', 'One'), + item('two', 'Two'))), + input('/data/not', + label('not (note)')), + t('trigger ref="/data/trig"', + label('trig (trigger)')) + ) + )); + + expect(scenario).toHaveSerializedSubmissionXML( + // prettier-ignore + t('data', + t('grp', + t('inp', defaults.inp ?? ''), + t('sel1', defaults.sel1 ?? ''), + t('selN', defaults.selN ?? '')), + t('not', defaults.not ?? ''), + t('trig', defaults.trig ?? ''), + t('subt', + t('modelVal', defaults.modelVal ?? '')), + t('calc', '3'), + t('meta', + t('instanceID', DEFAULT_INSTANCE_ID))).asXml() + ); + }); + + // The original ported JavaRosa test exercising Unicode support was a good + // reminder that we have an outstanding issue to support Unicode + // normalization in `@getodk/xpath` (i.e. consistent use of combined forms, + // see https://github.com/getodk/web-forms/issues/175). + describe('unicode', () => { + const decomposed = 'é'; + const composed = 'é'; + + const getUnicodeScenario = async (defaultValue = '') => { + // prettier-ignore + return Scenario.init('Unicode normalization', html( + head( + title('Unicode normalization'), + model( + mainInstance( + t('data id="unicode-normalization"', + t('rep', + t('inp', defaultValue)), + t('meta', t('instanceID'))) + ), + bind('/data/rep/inp'), + bind('/data/meta/instanceID').calculate(`'${DEFAULT_INSTANCE_ID}'`) + ) + ), + body( + repeat('/data/rep', + label('rep'), + input('/data/rep/inp', + label('inp')))) + )); + }; + + // Check setup assumptions + beforeEach(() => { + // 1. `decomposed` and `composed` are equivalent + expect(decomposed.normalize()).toBe(composed); + + // 2. `decomposed` and `composed` are NOT equal + expect(decomposed).not.toBe(composed); + }); + + it('normalizes combining characters in a default value to their composed form', async () => { + const scenario = await getUnicodeScenario(decomposed); + + expect(scenario).toHaveSerializedSubmissionXML( + // prettier-ignore + t('data', + t('rep', + t('inp', composed)), + t('meta', + t('instanceID', DEFAULT_INSTANCE_ID))).asXml() + ); + }); + + it('normalizes combining characters in an assigned value to their composed form', async () => { + const scenario = await getUnicodeScenario(); + + scenario.answer('/data/rep[1]/inp', decomposed); + + expect(scenario).toHaveSerializedSubmissionXML( + // prettier-ignore + t('data', + t('rep', + t('inp', composed)), + t('meta', + t('instanceID', DEFAULT_INSTANCE_ID))).asXml() + ); + }); + }); + + describe('repeats', () => { + let scenario: Scenario; + + beforeEach(async () => { + // prettier-ignore + scenario = await Scenario.init('XML serialization - repeats', html( + head( + title('XML serialization - repeats'), + model( + mainInstance( + t('data id="xml-serialization-repeats"', + t('rep jr:template=""', + t('inp')), + t('meta', t('instanceID'))) + ), + bind('/data/rep/inp').type('string'), + bind('/data/meta/instanceID').calculate(`'${DEFAULT_INSTANCE_ID}'`) + ) + ), + body( + group('/data/rep', + repeat('/data/rep', + input('/data/rep/inp')))) + )); + }); + + it('does not serialize an element for a repeat range', () => { + expect(scenario).toHaveSerializedSubmissionXML( + // prettier-ignore + t('data', + t('meta', + t('instanceID', DEFAULT_INSTANCE_ID))).asXml() + ); + }); + + it('serializes each repeat instance and its descendants', () => { + scenario.createNewRepeat('/data/rep'); + scenario.answer('/data/rep[1]/inp', 'a'); + scenario.createNewRepeat('/data/rep'); + scenario.answer('/data/rep[2]/inp', 'b'); + + expect(scenario).toHaveSerializedSubmissionXML( + // prettier-ignore + t('data', + t('rep', + t('inp', 'a')), + t('rep', + t('inp', 'b')), + t('meta', + t('instanceID', DEFAULT_INSTANCE_ID))).asXml() + ); + + scenario.removeRepeat('/data/rep[1]'); + + expect(scenario).toHaveSerializedSubmissionXML( + // prettier-ignore + t('data', + t('rep', + t('inp', 'b')), + t('meta', + t('instanceID', DEFAULT_INSTANCE_ID))).asXml() + ); + }); + }); + + describe('relevance', () => { + let scenario: Scenario; + + beforeEach(async () => { + // prettier-ignore + scenario = await Scenario.init('XML serialization - relevance', html( + head( + title('XML serialization - relevance'), + model( + mainInstance( + t('data id="xml-serialization-relevance"', + t('grp-rel', '1'), + t('inp-rel', '1'), + t('grp', + t('inp', 'inp default value')), + t('meta', t('instanceID'))) + ), + bind('/data/grp-rel'), + bind('/data/inp-rel'), + bind('/data/grp').relevant('/data/grp-rel = 1'), + bind('/data/grp/inp').relevant('/data/inp-rel = 1'), + bind('/data/meta/instanceID').calculate(`'${DEFAULT_INSTANCE_ID}'`) + ) + ), + body( + input('/data/grp-rel', + label('`grp` is relevant when this value is 1')), + input('/data/inp-rel', + label('`inp` is relevant when this value is 1')), + group('/data/grp', + label('grp'), + + input('/data/grp/inp', + label('inp')))) + )) + }); + + it('omits non-relevant leaf nodes', () => { + scenario.answer('/data/inp-rel', '0'); + + expect(scenario).toHaveSerializedSubmissionXML( + // prettier-ignore + t('data', + t('grp-rel', '1'), + t('inp-rel', '0'), + t('grp'), + t('meta', + t('instanceID', DEFAULT_INSTANCE_ID))).asXml() + ); + }); + + it('omits non-relevant subtree nodes', () => { + scenario.answer('/data/grp-rel', '0'); + + expect(scenario).toHaveSerializedSubmissionXML( + // prettier-ignore + t('data', + t('grp-rel', '0'), + t('inp-rel', '1'), + t('meta', + t('instanceID', DEFAULT_INSTANCE_ID))).asXml() + ); + }); + }); + }); }); diff --git a/packages/xforms-engine/src/lib/xml-serialization.ts b/packages/xforms-engine/src/lib/xml-serialization.ts index a21b261f3..ba8b0af79 100644 --- a/packages/xforms-engine/src/lib/xml-serialization.ts +++ b/packages/xforms-engine/src/lib/xml-serialization.ts @@ -92,5 +92,5 @@ export const serializeParentElementXML = ( }; export const serializeLeafElementXML = (nodeName: string, xmlValue: EscapedXMLText): string => { - return serializeElementXML(nodeName, xmlValue); + return serializeElementXML(nodeName, xmlValue.normalize()); }; From 93a25b239122030e7ade97ea6b8ab1e48653121b Mon Sep 17 00:00:00 2001 From: eyelidlessness Date: Wed, 25 Sep 2024 15:14:12 -0700 Subject: [PATCH 11/18] scenario: add support for testing client reactivity (Solid) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is being introduced now to support testing client reactivity of submission serialization, since we’ve discussed that being a desirable quality to have ready should it make sense to leverage. There are probably dozens if not hundreds of other existing/future test cases where we’d want better coverage of client reactivity at the integration level. This should hopefully be general enough to allow for that as bandwidth allows. --- packages/scenario/src/client/init.ts | 38 +++++++-------- packages/scenario/src/jr/Scenario.ts | 46 +++++++++++++++++-- .../scenario/src/reactive/ReactiveScenario.ts | 46 +++++++++++++++++++ 3 files changed, 105 insertions(+), 25 deletions(-) create mode 100644 packages/scenario/src/reactive/ReactiveScenario.ts diff --git a/packages/scenario/src/client/init.ts b/packages/scenario/src/client/init.ts index 00affd394..03666f3e7 100644 --- a/packages/scenario/src/client/init.ts +++ b/packages/scenario/src/client/init.ts @@ -1,10 +1,11 @@ import type { XFormsElement } from '@getodk/common/test/fixtures/xform-dsl/XFormsElement.ts'; -import { - initializeForm, - type EngineConfig, - type FormResource, - type RootNode, +import type { + EngineConfig, + FormResource, + OpaqueReactiveObjectFactory, + RootNode, } from '@getodk/xforms-engine'; +import { initializeForm } from '@getodk/xforms-engine'; import type { Owner } from 'solid-js'; import { createRoot, getOwner, runWithOwner } from 'solid-js'; import { FormDefinitionResource } from '../jr/resource/FormDefinitionResource.ts'; @@ -51,24 +52,13 @@ const fetchResourceStub: typeof fetch = () => { throw new Error('TODO: resource fetching not implemented'); }; -/** - * Satisfies the xforms-engine client `stateFactory` option. Currently this is - * intentionally **not** reactive, as the current scenario tests (as - * ported/derived from JavaRosa's test suite) do not explicitly exercise any - * reactive aspects of the client interface. - * - * @todo It **is possible** to use Solid's `createMutable`, which would enable - * expansion of the JavaRosa test suite to _also_ test reactivity. In local - * testing during the migration to the new client interface, no additional - * changes were necessary to make that change. For now this non-reactive factory - * is supplied as a validation that reactivity is in fact optional. - */ -const nonReactiveIdentityStateFactory = (value: T): T => value; +export interface InitializeTestFormOptions { + readonly stateFactory: OpaqueReactiveObjectFactory; +} const defaultConfig = { fetchResource: fetchResourceStub, - stateFactory: nonReactiveIdentityStateFactory, -} as const satisfies EngineConfig; +} as const satisfies Omit; interface InitializedTestForm { readonly instanceRoot: RootNode; @@ -77,7 +67,8 @@ interface InitializedTestForm { } export const initializeTestForm = async ( - testForm: TestFormResource + testForm: TestFormResource, + options: InitializeTestFormOptions ): Promise => { return createRoot(async (dispose) => { const owner = getOwner(); @@ -89,7 +80,10 @@ export const initializeTestForm = async ( const formResource = await getFormResource(testForm); const instanceRoot = await runWithOwner(owner, async () => { return initializeForm(formResource, { - config: defaultConfig, + config: { + ...defaultConfig, + stateFactory: options.stateFactory, + }, }); })!; diff --git a/packages/scenario/src/jr/Scenario.ts b/packages/scenario/src/jr/Scenario.ts index 3b8b37c3f..2d300afeb 100644 --- a/packages/scenario/src/jr/Scenario.ts +++ b/packages/scenario/src/jr/Scenario.ts @@ -1,6 +1,7 @@ import type { XFormsElement } from '@getodk/common/test/fixtures/xform-dsl/XFormsElement.ts'; import type { AnyNode, + OpaqueReactiveObjectFactory, RepeatRangeControlledNode, RepeatRangeNode, RepeatRangeUncontrolledNode, @@ -13,12 +14,13 @@ import { afterEach, expect } from 'vitest'; import { SelectValuesAnswer } from '../answer/SelectValuesAnswer.ts'; import type { ValueNodeAnswer } from '../answer/ValueNodeAnswer.ts'; import { answerOf } from '../client/answerOf.ts'; -import type { TestFormResource } from '../client/init.ts'; +import type { InitializeTestFormOptions, TestFormResource } from '../client/init.ts'; import { initializeTestForm } from '../client/init.ts'; import { isRepeatRange } from '../client/predicates.ts'; import { getClosestRepeatRange, getNodeForReference } from '../client/traversal.ts'; import { ImplementationPendingError } from '../error/ImplementationPendingError.ts'; import { UnclearApplicabilityError } from '../error/UnclearApplicabilityError.ts'; +import type { ReactiveScenario } from '../reactive/ReactiveScenario.ts'; import type { JRFormEntryCaption } from './caption/JRFormEntryCaption.ts'; import type { BeginningOfFormEvent } from './event/BeginningOfFormEvent.ts'; import type { EndOfFormEvent } from './event/EndOfFormEvent.ts'; @@ -42,10 +44,34 @@ import { ValidateOutcome } from './validation/ValidateOutcome.ts'; import { JREvaluationContext } from './xpath/JREvaluationContext.ts'; import { JRTreeReference } from './xpath/JRTreeReference.ts'; -interface ScenarioConstructorOptions { +/** + * Satisfies the xforms-engine client `stateFactory` option. Currently this is + * intentionally **not** reactive, as scenario tests ported/derived from + * JavaRosa's test suite do not explicitly exercise any reactive aspects of the + * engine/client interface. + * + * This identity function is used as the default + * {@link ScenarioConstructorOptions.stateFactory} for tests using + * {@link Scenario.init}. + * + * The {@link ReactiveScenario} subclass provides a default client reactivity + * implementation for tests directly exercising the engine's reactive APIs and + * behaviors. + */ +const nonReactiveIdentityStateFactory = (value: T): T => value; + +export interface ScenarioConstructorOptions { readonly dispose: VoidFunction; readonly formName: string; readonly instanceRoot: RootNode; + + /** + * No reactivity is provided by default. + * + * @see {@link ReactiveScenario} for tests exercising reactive engine/client + * functionality. + */ + readonly stateFactory?: OpaqueReactiveObjectFactory; } type FormFileName = `${string}.xml`; @@ -56,6 +82,7 @@ const isFormFileName = (value: FormDefinitionResource | string): value is FormFi // prettier-ignore type ScenarioStaticInitParameters = + | readonly [formFileName: FormFileName] | readonly [formName: string, form: XFormsElement] | readonly [resource: FormDefinitionResource]; @@ -117,6 +144,14 @@ const isAnswerSelectParams = (args: AnswerParameters): args is AnswerSelectParam * to clarify their branchiness at both call and implementation sites. */ export class Scenario { + /** + * To be overridden, e.g. by {@link ReactiveScenario}. + */ + // eslint-disable-next-line @typescript-eslint/class-literal-property-style + static get initializeTestFormOptions(): InitializeTestFormOptions | null { + return null; + } + static async init( this: This, ...args: ScenarioStaticInitParameters @@ -137,7 +172,12 @@ export class Scenario { resource = form; } - const { dispose, owner, instanceRoot } = await initializeTestForm(resource); + const options: InitializeTestFormOptions = { + stateFactory: nonReactiveIdentityStateFactory, + ...this.initializeTestFormOptions, + }; + + const { dispose, owner, instanceRoot } = await initializeTestForm(resource, options); return runWithOwner(owner, () => { return new this({ diff --git a/packages/scenario/src/reactive/ReactiveScenario.ts b/packages/scenario/src/reactive/ReactiveScenario.ts new file mode 100644 index 000000000..dd724094e --- /dev/null +++ b/packages/scenario/src/reactive/ReactiveScenario.ts @@ -0,0 +1,46 @@ +import type { EffectFunction, Owner } from 'solid-js'; +import { createEffect, createRoot, getOwner, runWithOwner } from 'solid-js'; +import { createMutable } from 'solid-js/store'; +import { assert } from 'vitest'; +import type { InitializeTestFormOptions } from '../client/init.ts'; +import { Scenario, type ScenarioConstructorOptions } from '../jr/Scenario.ts'; + +export class ReactiveScenario extends Scenario { + static override get initializeTestFormOptions(): InitializeTestFormOptions { + return { + stateFactory: createMutable, + }; + } + + private readonly testScopedOwner: Owner; + + constructor(options: ScenarioConstructorOptions) { + let dispose: VoidFunction; + + const testScopedOwner = createRoot((disposeFn) => { + dispose = disposeFn; + + const owner = getOwner(); + + assert(owner); + + return owner; + }); + + super({ + ...options, + dispose: () => { + dispose(); + options.dispose(); + }, + }); + + this.testScopedOwner = testScopedOwner; + } + + createEffect(fn: EffectFunction | undefined, Next>): void { + runWithOwner(this.testScopedOwner, () => { + createEffect(fn); + }); + } +} From e86a52a9bf0af1364063d428b5fd4929d22e2622 Mon Sep 17 00:00:00 2001 From: eyelidlessness Date: Fri, 27 Sep 2024 11:42:33 -0700 Subject: [PATCH 12/18] scenario: test client reactivity of submission serialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exercises: - Basic leaf node value state changes - Repeats: - Adding instances - State changes in each instance - Removing instances - Relevance changes within multiple repeat instances Note: these tests (and all the others added to this suite as part of the submission engine API implementation) had originally been written as lower level (unit-ish) tests within `@getodk/xforms-engine`. Testing reactivity there unfortunately resurfaced the cursed `InconsistentChildrenStateError`. Which means we have somehow still not eliminated the fundamental root cause of that behavior! I have however validated that the same behavior under test works: - here, as demonstrated by the tests themselves - in both the `web-forms` and `ui-solid` UI clients While I’m not thrilled that the issue continues to hang over us, it is encouraging that previous efforts to narrow its impact have not totally failed us for this particular feature. --- packages/scenario/test/submission.test.ts | 239 ++++++++++++++++++++++ 1 file changed, 239 insertions(+) diff --git a/packages/scenario/test/submission.test.ts b/packages/scenario/test/submission.test.ts index 38737c93b..927af1009 100644 --- a/packages/scenario/test/submission.test.ts +++ b/packages/scenario/test/submission.test.ts @@ -16,6 +16,7 @@ import { } from '@getodk/common/test/fixtures/xform-dsl/index.ts'; import { beforeEach, describe, expect, it } from 'vitest'; import { Scenario } from '../src/jr/Scenario.ts'; +import { ReactiveScenario } from '../src/reactive/ReactiveScenario.ts'; describe('Form submission', () => { describe('XFormSerializingVisitorTest.java', () => { @@ -350,5 +351,243 @@ describe('Form submission', () => { ); }); }); + + describe('client reactivity', () => { + let scenario: ReactiveScenario; + + beforeEach(async () => { + scenario = await ReactiveScenario.init( + 'XML serialization - client reactivity', + // prettier-ignore + html( + head( + title('Relevance XML serialization'), + model( + mainInstance( + t('data id="relevance-xml-serialization"', + t('rep-inp-rel'), + t('rep', + t('inp')), + t('meta', t('instanceID'))) + ), + bind('/data/rep-inp-rel'), + bind('/data/rep/inp').relevant("/data/rep-inp-rel = '' or /data/rep-inp-rel = position(..)"), + bind('/data/meta/instanceID').calculate(`'${DEFAULT_INSTANCE_ID}'`) + ) + ), + body( + input('/data/rep-inp-rel', + label('Each /data/rep/inp is relevant when this value is 1')), + repeat('/data/rep', + label('rep'), + input('/data/rep/inp', + label('inp')))) + ) + ); + }); + + it('updates XML serialization state on change to string node', () => { + let serialized: string | null = null; + + scenario.createEffect(() => { + serialized = scenario.proposed_serializeInstance(); + }); + + // Default serialization before any state change + expect(serialized).toBe( + // prettier-ignore + t('data', + t('rep-inp-rel'), + t('rep', + t('inp')), + t('meta', + t('instanceID', DEFAULT_INSTANCE_ID))).asXml() + ); + + // Check reactive update for repeated changes + for (let i = 0; i < 10; i += 1) { + scenario.answer('/data/rep[1]/inp', `${i}`); + + // After first value change + expect(serialized).toBe( + // prettier-ignore + t('data', + t('rep-inp-rel'), + t('rep', + t('inp', `${i}`)), + t('meta', + t('instanceID', DEFAULT_INSTANCE_ID))).asXml() + ); + } + }); + + it('updates XML serialization state when adding and removing repeat instances', () => { + let serialized: string | null = null; + + scenario.createEffect(() => { + serialized = scenario.proposed_serializeInstance(); + }); + + // Default serialization before any state change + expect(serialized).toBe( + // prettier-ignore + t('data', + t('rep-inp-rel'), + t('rep', + t('inp')), + t('meta', + t('instanceID', DEFAULT_INSTANCE_ID))).asXml() + ); + + scenario.createNewRepeat('/data/rep'); + + // First repeat instance added + expect(serialized).toBe( + // prettier-ignore + t('data', + t('rep-inp-rel'), + t('rep', + t('inp')), + t('rep', + t('inp')), + t('meta', + t('instanceID', DEFAULT_INSTANCE_ID))).asXml() + ); + + scenario.createNewRepeat('/data/rep'); + + // Second repeat instance added + expect(serialized).toBe( + // prettier-ignore + t('data', + t('rep-inp-rel'), + t('rep', + t('inp')), + t('rep', + t('inp')), + t('rep', + t('inp')), + t('meta', + t('instanceID', DEFAULT_INSTANCE_ID))).asXml() + ); + + scenario.answer('/data/rep[1]/inp', 'rep 1 inp'); + scenario.answer('/data/rep[2]/inp', 'rep 2 inp'); + scenario.answer('/data/rep[3]/inp', 'rep 3 inp'); + + // Each of the above values set + expect(serialized).toBe( + // prettier-ignore + t('data', + t('rep-inp-rel'), + t('rep', + t('inp', 'rep 1 inp')), + t('rep', + t('inp', 'rep 2 inp')), + t('rep', + t('inp', 'rep 3 inp')), + t('meta', + t('instanceID', DEFAULT_INSTANCE_ID))).asXml() + ); + + scenario.removeRepeat('/data/rep[3]'); + + // Last repeat instance removed + expect(serialized).toBe( + // prettier-ignore + t('data', + t('rep-inp-rel'), + t('rep', + t('inp', 'rep 1 inp')), + t('rep', + t('inp', 'rep 2 inp')), + t('meta', + t('instanceID', DEFAULT_INSTANCE_ID))).asXml() + ); + + scenario.removeRepeat('/data/rep[1]'); + + // First repeat instance removed + expect(serialized).toBe( + // prettier-ignore + t('data', + t('rep-inp-rel'), + t('rep', + t('inp', 'rep 2 inp')), + t('meta', + t('instanceID', DEFAULT_INSTANCE_ID))).asXml() + ); + + scenario.removeRepeat('/data/rep[1]'); + + // All repeat instances removed + expect(serialized).toBe( + // prettier-ignore + t('data', + t('rep-inp-rel'), + t('meta', + t('instanceID', DEFAULT_INSTANCE_ID))).asXml() + ); + }); + + it('updates XML serialization state when relevance changes', () => { + let serialized: string | null = null; + + scenario.createEffect(() => { + serialized = scenario.proposed_serializeInstance(); + }); + + scenario.createNewRepeat('/data/rep'); + scenario.createNewRepeat('/data/rep'); + scenario.answer('/data/rep[1]/inp', 'rep 1 inp'); + scenario.answer('/data/rep[2]/inp', 'rep 2 inp'); + scenario.answer('/data/rep[3]/inp', 'rep 3 inp'); + + // Current serialization before any relevance change + expect(serialized).toBe( + // prettier-ignore + t('data', + t('rep-inp-rel'), + t('rep', + t('inp', 'rep 1 inp')), + t('rep', + t('inp', 'rep 2 inp')), + t('rep', + t('inp', 'rep 3 inp')), + t('meta', + t('instanceID', DEFAULT_INSTANCE_ID))).asXml() + ); + + scenario.answer('/data/rep-inp-rel', '1'); + + // Non-relevant /data/rep[position() != '1']/inp omitted + expect(serialized).toBe( + // prettier-ignore + t('data', + t('rep-inp-rel', '1'), + t('rep', + t('inp', 'rep 1 inp')), + t('rep'), + t('rep'), + t('meta', + t('instanceID', DEFAULT_INSTANCE_ID))).asXml() + ); + + scenario.answer('/data/rep-inp-rel', '3'); + + // Non-relevant /data/rep[position() != '3']/inp omitted + expect(serialized).toBe( + // prettier-ignore + t('data', + t('rep-inp-rel', '3'), + t('rep'), + t('rep'), + t('rep', + t('inp', 'rep 3 inp')), + t('meta', + t('instanceID', DEFAULT_INSTANCE_ID))).asXml() + ); + }); + }); }); }); From 4c48949f6e51b3366709aeb0c4edc67465c79d36 Mon Sep 17 00:00:00 2001 From: eyelidlessness Date: Thu, 26 Sep 2024 15:56:46 -0700 Subject: [PATCH 13/18] engine: parse , implementing SubmissionDefinition --- packages/xforms-engine/src/lib/dom/query.ts | 7 +++ .../parse/model/FormSubmissionDefinition.ts | 44 +++++++++++++++++++ .../src/parse/model/ModelDefinition.ts | 4 +- .../src/parse/model/RootDefinition.ts | 2 + 4 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 packages/xforms-engine/src/parse/model/FormSubmissionDefinition.ts diff --git a/packages/xforms-engine/src/lib/dom/query.ts b/packages/xforms-engine/src/lib/dom/query.ts index c50fb1153..89b46be94 100644 --- a/packages/xforms-engine/src/lib/dom/query.ts +++ b/packages/xforms-engine/src/lib/dom/query.ts @@ -15,6 +15,7 @@ const repeatGroupLabelLookup = new ScopedElementLookup( ); const repeatLookup = new ScopedElementLookup(':scope > repeat[nodeset]', 'repeat[nodeset]'); const valueLookup = new ScopedElementLookup(':scope > value', 'value'); +const submissionLookup = new ScopedElementLookup(':scope > submission', 'submission'); export interface HintElement extends LocalNamedElement<'hint'> {} @@ -60,3 +61,9 @@ export const getRepeatElement = (parent: Element): RepeatElement | null => { export const getValueElement = (parent: ItemElement | ItemsetElement): ValueElement | null => { return valueLookup.getElement(parent); }; + +export interface SubmissionElement extends LocalNamedElement<'submission'> {} + +export const getSubmissionElement = (parent: Element): SubmissionElement | null => { + return submissionLookup.getElement(parent); +}; diff --git a/packages/xforms-engine/src/parse/model/FormSubmissionDefinition.ts b/packages/xforms-engine/src/parse/model/FormSubmissionDefinition.ts new file mode 100644 index 000000000..929e76ffc --- /dev/null +++ b/packages/xforms-engine/src/parse/model/FormSubmissionDefinition.ts @@ -0,0 +1,44 @@ +import type { SubmissionDefinition } from '../../client/submission/SubmissionDefinition.ts'; +import { getSubmissionElement } from '../../lib/dom/query.ts'; +import type { XFormDOM } from '../XFormDOM.ts'; + +export class FormSubmissionDefinition implements SubmissionDefinition { + readonly submissionAction!: URL | null; + readonly submissionMethod = 'post'; + readonly encryptionKey!: string | null; + + constructor(xformDOM: XFormDOM) { + const submissionElement = getSubmissionElement(xformDOM.model); + + let submissionAction: URL | null = null; + let submissionMethod: 'post'; + let encryptionKey: string | null = null; + + if (submissionElement == null) { + submissionAction = null; + submissionMethod = 'post'; + encryptionKey = null; + } else { + const method = submissionElement.getAttribute('method')?.trim(); + + if (method == null || method === 'post' || method === 'form-data-post') { + submissionMethod = 'post'; + } else { + throw new Error(`Unexpected : ${method}`); + } + + const action = submissionElement.getAttribute('action'); + + if (action != null) { + // TODO: this is known-fallible. + submissionAction = new URL(action.trim()); + } + + encryptionKey = submissionElement.getAttribute('base64RsaPublicKey'); + } + + this.submissionAction = submissionAction; + this.submissionMethod = submissionMethod; + this.encryptionKey = encryptionKey; + } +} diff --git a/packages/xforms-engine/src/parse/model/ModelDefinition.ts b/packages/xforms-engine/src/parse/model/ModelDefinition.ts index 2ad57f925..98cecb923 100644 --- a/packages/xforms-engine/src/parse/model/ModelDefinition.ts +++ b/packages/xforms-engine/src/parse/model/ModelDefinition.ts @@ -1,4 +1,5 @@ import type { XFormDefinition } from '../XFormDefinition.ts'; +import { FormSubmissionDefinition } from './FormSubmissionDefinition.ts'; import { ModelBindMap } from './ModelBindMap.ts'; import { RootDefinition } from './RootDefinition.ts'; @@ -7,8 +8,9 @@ export class ModelDefinition { readonly root: RootDefinition; constructor(readonly form: XFormDefinition) { + const submission = new FormSubmissionDefinition(form.xformDOM); this.binds = ModelBindMap.fromModel(this); - this.root = new RootDefinition(form, this, form.body.classes); + this.root = new RootDefinition(form, this, submission, form.body.classes); } toJSON() { diff --git a/packages/xforms-engine/src/parse/model/RootDefinition.ts b/packages/xforms-engine/src/parse/model/RootDefinition.ts index 24f3ab89e..6b50e374b 100644 --- a/packages/xforms-engine/src/parse/model/RootDefinition.ts +++ b/packages/xforms-engine/src/parse/model/RootDefinition.ts @@ -1,6 +1,7 @@ import type { BodyClassList } from '../body/BodyDefinition.ts'; import type { XFormDefinition } from '../XFormDefinition.ts'; import type { BindDefinition } from './BindDefinition.ts'; +import type { FormSubmissionDefinition } from './FormSubmissionDefinition.ts'; import { LeafNodeDefinition } from './LeafNodeDefinition.ts'; import type { ModelDefinition } from './ModelDefinition.ts'; import type { @@ -31,6 +32,7 @@ export class RootDefinition implements NodeDefinition<'root'> { constructor( protected readonly form: XFormDefinition, protected readonly model: ModelDefinition, + readonly submission: FormSubmissionDefinition, readonly classes: BodyClassList ) { // TODO: theoretically the pertinent step in the bind's `nodeset` *could* be From e7a511f5f58dfe976786ed3f47ea45e79cc6281e Mon Sep 17 00:00:00 2001 From: eyelidlessness Date: Thu, 26 Sep 2024 15:57:50 -0700 Subject: [PATCH 14/18] scenario: add method aliased to RootNode `prepareSubmission` --- packages/scenario/src/jr/Scenario.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/scenario/src/jr/Scenario.ts b/packages/scenario/src/jr/Scenario.ts index 2d300afeb..4cc3b9613 100644 --- a/packages/scenario/src/jr/Scenario.ts +++ b/packages/scenario/src/jr/Scenario.ts @@ -7,6 +7,9 @@ import type { RepeatRangeUncontrolledNode, RootNode, SelectNode, + SubmissionChunkedType, + SubmissionOptions, + SubmissionResult, } from '@getodk/xforms-engine'; import type { Accessor, Setter } from 'solid-js'; import { createMemo, createSignal, runWithOwner } from 'solid-js'; @@ -944,6 +947,19 @@ export class Scenario { return this.instanceRoot.submissionState.submissionXML; } + /** + * @todo Name is currently Web Forms-specific, pending question on whether + * this feature set is novel to Web Forms. If it is novel, isn't clear whether + * it would be appropriate to propose an equivalent JavaRosa method. Find out + * more about Collect's responsibility for submission (beyond serialization, + * already handled by {@link proposed_serializeInstance}). + */ + prepareWebFormsSubmission( + options?: SubmissionOptions + ): Promise> { + return this.instanceRoot.prepareSubmission(options); + } + // TODO: consider adapting tests which use the following interfaces to use // more portable concepts (either by using conceptually similar `Scenario` // APIs, or by reframing the tests' logic to the same behavioral concerns with From 246b78cb48fa08e0a92fe47175b01d3ed7c8260a Mon Sep 17 00:00:00 2001 From: eyelidlessness Date: Thu, 26 Sep 2024 15:58:46 -0700 Subject: [PATCH 15/18] scenario: assertion extensions for aspects of `prepareSubmission`/`SubmissionResult` --- .../src/lib/type-assertions/assertNull.ts | 7 + .../lib/type-assertions/assertUnknownArray.ts | 9 + .../common/src/test/assertions/helpers.ts | 8 +- .../AsyncAsymmetricTypedExpectExtension.ts | 41 +++++ .../expandAsyncExpectExtensionResult.ts | 53 ++++++ .../expandSimpleExpectExtensionResult.ts | 11 +- .../src/test/assertions/vitest/isErrorLike.ts | 5 + .../vitest/shared-extension-types.ts | 25 ++- .../src/assertion/extensions/submission.ts | 165 +++++++++++++++++- 9 files changed, 305 insertions(+), 19 deletions(-) create mode 100644 packages/common/src/lib/type-assertions/assertNull.ts create mode 100644 packages/common/src/lib/type-assertions/assertUnknownArray.ts create mode 100644 packages/common/src/test/assertions/vitest/AsyncAsymmetricTypedExpectExtension.ts create mode 100644 packages/common/src/test/assertions/vitest/expandAsyncExpectExtensionResult.ts create mode 100644 packages/common/src/test/assertions/vitest/isErrorLike.ts diff --git a/packages/common/src/lib/type-assertions/assertNull.ts b/packages/common/src/lib/type-assertions/assertNull.ts new file mode 100644 index 000000000..51346a727 --- /dev/null +++ b/packages/common/src/lib/type-assertions/assertNull.ts @@ -0,0 +1,7 @@ +export type AssertNull = (value: unknown) => asserts value is null; + +export const assertNull: AssertNull = (value) => { + if (value !== null) { + throw new Error('Not null'); + } +}; diff --git a/packages/common/src/lib/type-assertions/assertUnknownArray.ts b/packages/common/src/lib/type-assertions/assertUnknownArray.ts new file mode 100644 index 000000000..48e85d53d --- /dev/null +++ b/packages/common/src/lib/type-assertions/assertUnknownArray.ts @@ -0,0 +1,9 @@ +type UnknownArray = readonly unknown[]; + +type AssertUnknownArray = (value: unknown) => asserts value is UnknownArray; + +export const assertUnknownArray: AssertUnknownArray = (value) => { + if (!Array.isArray(value)) { + throw new Error('Not an array'); + } +}; diff --git a/packages/common/src/test/assertions/helpers.ts b/packages/common/src/test/assertions/helpers.ts index 3482f375b..4cf1482b3 100644 --- a/packages/common/src/test/assertions/helpers.ts +++ b/packages/common/src/test/assertions/helpers.ts @@ -3,12 +3,14 @@ export { instanceAssertion } from './instanceAssertion.ts'; export { typeofAssertion } from './typeofAssertion.ts'; export { ArbitraryConditionExpectExtension } from './vitest/ArbitraryConditionExpectExtension.ts'; export { AsymmetricTypedExpectExtension } from './vitest/AsymmetricTypedExpectExtension.ts'; -export { InspectableComparisonError } from './vitest/InspectableComparisonError.ts'; -export { StaticConditionExpectExtension } from './vitest/StaticConditionExpectExtension.ts'; -export { SymmetricTypedExpectExtension } from './vitest/SymmetricTypedExpectExtension.ts'; +export { AsyncAsymmetricTypedExpectExtension } from './vitest/AsyncAsymmetricTypedExpectExtension.ts'; export { extendExpect } from './vitest/extendExpect.ts'; +export { InspectableComparisonError } from './vitest/InspectableComparisonError.ts'; +export { InspectableStaticConditionError } from './vitest/InspectableStaticConditionError.ts'; export type { CustomInspectable, DeriveStaticVitestExpectExtension, Inspectable, } from './vitest/shared-extension-types.ts'; +export { StaticConditionExpectExtension } from './vitest/StaticConditionExpectExtension.ts'; +export { SymmetricTypedExpectExtension } from './vitest/SymmetricTypedExpectExtension.ts'; diff --git a/packages/common/src/test/assertions/vitest/AsyncAsymmetricTypedExpectExtension.ts b/packages/common/src/test/assertions/vitest/AsyncAsymmetricTypedExpectExtension.ts new file mode 100644 index 000000000..be6bca631 --- /dev/null +++ b/packages/common/src/test/assertions/vitest/AsyncAsymmetricTypedExpectExtension.ts @@ -0,0 +1,41 @@ +import type { SyncExpectationResult } from 'vitest'; +import type { AssertIs } from '../../../../types/assertions/AssertIs.ts'; +import { expandAsyncExpectExtensionResult } from './expandAsyncExpectExtensionResult.ts'; +import type { ExpectExtensionMethod, SimpleAssertionResult } from './shared-extension-types.ts'; +import { validatedExtensionMethod } from './validatedExtensionMethod.ts'; + +/** + * Generalizes definition of a Vitest `expect` API extension where the assertion + * expects differing types for its `actual` and `expected` parameters, and: + * + * - Automatically perfoms runtime validation of those parameters, helping to + * ensure that the extensions' static types are consistent with the runtime + * values passed in a given test's assertions + * + * - Expands simplified assertion result types to the full interface expected by + * Vitest + * + * - Facilitates deriving and defining corresponding static types on the base + * `expect` type + */ +export class AsyncAsymmetricTypedExpectExtension< + Actual = unknown, + Expected = Actual, + Result extends SimpleAssertionResult = SimpleAssertionResult, +> { + readonly extensionMethod: ExpectExtensionMethod>; + + constructor( + readonly validateActualArgument: AssertIs, + readonly validateExpectedArgument: AssertIs, + extensionMethod: ExpectExtensionMethod> + ) { + const validatedMethod = validatedExtensionMethod( + validateActualArgument, + validateExpectedArgument, + extensionMethod + ); + + this.extensionMethod = expandAsyncExpectExtensionResult(validatedMethod); + } +} diff --git a/packages/common/src/test/assertions/vitest/expandAsyncExpectExtensionResult.ts b/packages/common/src/test/assertions/vitest/expandAsyncExpectExtensionResult.ts new file mode 100644 index 000000000..afacf5b4a --- /dev/null +++ b/packages/common/src/test/assertions/vitest/expandAsyncExpectExtensionResult.ts @@ -0,0 +1,53 @@ +import type { SyncExpectationResult } from 'vitest'; +import type { expandSimpleExpectExtensionResult } from './expandSimpleExpectExtensionResult.ts'; +import { isErrorLike } from './isErrorLike.ts'; +import type { ExpectExtensionMethod, SimpleAssertionResult } from './shared-extension-types.ts'; + +/** + * Asynchronous counterpart to {@link expandSimpleExpectExtensionResult} + */ +export const expandAsyncExpectExtensionResult = ( + simpleMethod: ExpectExtensionMethod> +): ExpectExtensionMethod> => { + return async (actual, expected) => { + const simpleResult = await simpleMethod(actual, expected); + + const pass = simpleResult === true; + + if (pass) { + return { + pass, + /** + * @todo It was previously assumed that it would never occur that an + * assertion would pass, and that Vitest would then produce a message + * for that. In hindsight, it makes sense that this case occurs in + * negated assertions (e.g. + * `expect(...).not.toPassSomeCustomAssertion`). It seems + * {@link SimpleAssertionResult} is not a good way to model the + * generalization, and that we may want a more uniform `AssertionResult` + * type which always includes both `pass` and `message` capabilities. + * This is should probably be addressed before we merge the big JR port + * PR, but is being temporarily put aside to focus on porting tests in + * bulk in anticipation of a scope change/hopefully-temporary + * interruption of momentum. + */ + message: () => { + throw new Error('Unsupported `SimpleAssertionResult` runtime value'); + }, + }; + } + + let message: () => string; + + if (isErrorLike(simpleResult)) { + message = () => simpleResult.message; + } else { + message = () => simpleResult; + } + + return { + pass, + message, + }; + }; +}; diff --git a/packages/common/src/test/assertions/vitest/expandSimpleExpectExtensionResult.ts b/packages/common/src/test/assertions/vitest/expandSimpleExpectExtensionResult.ts index d97853374..ac4e2f5ef 100644 --- a/packages/common/src/test/assertions/vitest/expandSimpleExpectExtensionResult.ts +++ b/packages/common/src/test/assertions/vitest/expandSimpleExpectExtensionResult.ts @@ -1,13 +1,6 @@ import type { SyncExpectationResult } from 'vitest'; -import type { - ErrorLike, - ExpectExtensionMethod, - SimpleAssertionResult, -} from './shared-extension-types.ts'; - -const isErrorLike = (result: SimpleAssertionResult): result is ErrorLike => { - return typeof result === 'object' && typeof result.message === 'string'; -}; +import { isErrorLike } from './isErrorLike.ts'; +import type { ExpectExtensionMethod, SimpleAssertionResult } from './shared-extension-types.ts'; /** * Where Vitest assertion extends may be defined to return a diff --git a/packages/common/src/test/assertions/vitest/isErrorLike.ts b/packages/common/src/test/assertions/vitest/isErrorLike.ts new file mode 100644 index 000000000..f8a3ff51e --- /dev/null +++ b/packages/common/src/test/assertions/vitest/isErrorLike.ts @@ -0,0 +1,5 @@ +import type { ErrorLike, SimpleAssertionResult } from './shared-extension-types.ts'; + +export const isErrorLike = (result: SimpleAssertionResult): result is ErrorLike => { + return typeof result === 'object' && typeof result.message === 'string'; +}; diff --git a/packages/common/src/test/assertions/vitest/shared-extension-types.ts b/packages/common/src/test/assertions/vitest/shared-extension-types.ts index 7e6ea42fd..909d307e2 100644 --- a/packages/common/src/test/assertions/vitest/shared-extension-types.ts +++ b/packages/common/src/test/assertions/vitest/shared-extension-types.ts @@ -3,6 +3,7 @@ import type { JSONValue } from '../../../../types/JSONValue.ts'; import type { Primitive } from '../../../../types/Primitive.ts'; import type { ArbitraryConditionExpectExtension } from './ArbitraryConditionExpectExtension.ts'; import type { AsymmetricTypedExpectExtension } from './AsymmetricTypedExpectExtension.ts'; +import type { AsyncAsymmetricTypedExpectExtension } from './AsyncAsymmetricTypedExpectExtension.ts'; import type { StaticConditionExpectExtension } from './StaticConditionExpectExtension.ts'; import type { SymmetricTypedExpectExtension } from './SymmetricTypedExpectExtension.ts'; @@ -37,14 +38,26 @@ export type ExpectExtensionMethod< // eslint-disable-next-line @typescript-eslint/no-explicit-any export type TypedExpectExtension = | AsymmetricTypedExpectExtension + | AsyncAsymmetricTypedExpectExtension | SymmetricTypedExpectExtension; -export type UntypedExpectExtensionFunction = ExpectExtensionMethod< +type AsyncUntypedExpectExtensionFunction = ExpectExtensionMethod< + unknown, + unknown, + Promise +>; + +type SyncUntypedExpectExtensionFunction = ExpectExtensionMethod< unknown, unknown, SyncExpectationResult >; +// prettier-ignore +export type UntypedExpectExtensionFunction = + | AsyncUntypedExpectExtensionFunction + | SyncUntypedExpectExtensionFunction; + export interface UntypedExpectExtensionObject { readonly extensionMethod: UntypedExpectExtensionFunction; } @@ -54,7 +67,10 @@ export type UntypedExpectExtension = | UntypedExpectExtensionFunction | UntypedExpectExtensionObject; -export type ExpectExtension = TypedExpectExtension | UntypedExpectExtension; +// prettier-ignore +export type ExpectExtension = + | TypedExpectExtension + | UntypedExpectExtension; export type ExpectExtensionRecord = { [K in MethodName]: ExpectExtension; @@ -68,7 +84,10 @@ export type DeriveStaticVitestExpectExtension< > = { [K in keyof Implementation]: // eslint-disable-next-line @typescript-eslint/no-explicit-any - Implementation[K] extends ArbitraryConditionExpectExtension + Implementation[K] extends AsyncAsymmetricTypedExpectExtension + ? (expected: Expected) => Promise + // eslint-disable-next-line @typescript-eslint/no-explicit-any + : Implementation[K] extends ArbitraryConditionExpectExtension ? () => VitestParameterizedReturn // eslint-disable-next-line @typescript-eslint/no-explicit-any : Implementation[K] extends StaticConditionExpectExtension diff --git a/packages/scenario/src/assertion/extensions/submission.ts b/packages/scenario/src/assertion/extensions/submission.ts index 241b80d4e..866a06dda 100644 --- a/packages/scenario/src/assertion/extensions/submission.ts +++ b/packages/scenario/src/assertion/extensions/submission.ts @@ -1,28 +1,185 @@ +import { assertUnknownArray } from '@getodk/common/lib/type-assertions/assertUnknownArray.ts'; +import { assertUnknownObject } from '@getodk/common/lib/type-assertions/assertUnknownObject.ts'; +import { getBlobText } from '@getodk/common/lib/web-compat/blob.ts'; import type { DeriveStaticVitestExpectExtension } from '@getodk/common/test/assertions/helpers.ts'; import { + ArbitraryConditionExpectExtension, AsymmetricTypedExpectExtension, + AsyncAsymmetricTypedExpectExtension, extendExpect, + instanceAssertion, } from '@getodk/common/test/assertions/helpers.ts'; +import type { SimpleAssertionResult } from '@getodk/common/test/assertions/vitest/shared-extension-types.ts'; +import type { AssertIs } from '@getodk/common/types/assertions/AssertIs.ts'; +import type { + SubmissionChunkedType, + SubmissionData, + SubmissionInstanceFile, + SubmissionResult, +} from '@getodk/xforms-engine'; +import { constants } from '@getodk/xforms-engine'; import { assert, expect } from 'vitest'; import { Scenario } from '../../jr/Scenario.ts'; import { ComparableXMLSerialization } from '../../serialization/ComparableXMLSerialization.ts'; import { assertString } from './shared-type-assertions.ts'; -type AssertScenario = (value: unknown) => asserts value is Scenario; +type AssertScenario = AssertIs; const assertScenario: AssertScenario = (value) => { assert(value instanceof Scenario); }; +const compareSubmissionXML = (actual: string, expected: string): SimpleAssertionResult => { + const comparableActual = new ComparableXMLSerialization(actual); + const comparableExpected = new ComparableXMLSerialization(expected); + + return comparableActual.equals(comparableExpected); +}; + +const assertFormData: AssertIs = instanceAssertion(FormData); + +type AnySubmissionResult = SubmissionResult; + +/** + * Validating the full {@link SubmissionResult} type is fairly involved. We + * check the basic object shape (expected keys present, gut check a few easy to + * check property types), on the assumption that downstream assertions will fail + * if the runtime and static types disagree. + * + * @todo If that assumption turns out to be wrong, it would make sense to do + * more complete validation here, serving as a smoke test for all tests + * exercising aspects of a prepared submission result. + */ +const assertSubmissionResult: AssertIs = (value) => { + assertUnknownObject(value); + assertString(value.status); + if (value.violations !== null) { + assertUnknownArray(value.violations); + } + assertUnknownObject(value.definition); + + if (Array.isArray(value.data)) { + value.data.forEach((item) => { + assertFormData(item); + }); + } else { + assertFormData(value.data); + } +}; + +const assertFile: AssertIs = instanceAssertion(File); + +const { SUBMISSION_INSTANCE_FILE_NAME, SUBMISSION_INSTANCE_FILE_TYPE } = constants; + +const assertSubmissionInstanceFile: AssertIs = (value) => { + assertFile(value); + + if (value.name !== SUBMISSION_INSTANCE_FILE_NAME) { + throw new Error(`Expected file named ${SUBMISSION_INSTANCE_FILE_NAME}, got ${value.name}`); + } + + if (value.type !== SUBMISSION_INSTANCE_FILE_TYPE) { + throw new Error(`Expected file of type ${SUBMISSION_INSTANCE_FILE_TYPE}, got ${value.type}`); + } +}; + +type ChunkedSubmissionData = readonly [SubmissionData, ...SubmissionData[]]; + +const isChunkedSubmissionData = ( + data: ChunkedSubmissionData | SubmissionData +): data is ChunkedSubmissionData => { + return Array.isArray(data); +}; + +const getSubmissionData = (submissionResult: AnySubmissionResult): SubmissionData => { + const { data } = submissionResult; + + if (isChunkedSubmissionData(data)) { + const [first] = data; + + return first; + } + + return data; +}; + +const getSubmissionInstanceFile = ( + submissionResult: AnySubmissionResult +): SubmissionInstanceFile => { + const submissionData = getSubmissionData(submissionResult); + const file = submissionData.get(SUBMISSION_INSTANCE_FILE_NAME); + + assertSubmissionInstanceFile(file); + + return file; +}; + export const submissionExtensions = extendExpect(expect, { toHaveSerializedSubmissionXML: new AsymmetricTypedExpectExtension( assertScenario, assertString, (actual, expected) => { - const comparableActual = new ComparableXMLSerialization(actual.proposed_serializeInstance()); - const comparableExpected = new ComparableXMLSerialization(expected); + const actualXML = actual.proposed_serializeInstance(); + + return compareSubmissionXML(actualXML, expected); + } + ), + + toBeReadyForSubmission: new ArbitraryConditionExpectExtension( + assertSubmissionResult, + (result) => { + try { + expect(result).toMatchObject({ + status: 'ready', + violations: null, + }); + + return true; + } catch (error) { + if (error instanceof Error) { + return error; + } + + // eslint-disable-next-line no-console + console.error(error); + return new Error('Unknown error'); + } + } + ), + + toBePendingSubmissionWithViolations: new ArbitraryConditionExpectExtension( + assertSubmissionResult, + (result) => { + try { + expect(result.status).toBe('pending'); + expect(result.violations).toMatchObject([expect.any(Object)]); + expect(result).toMatchObject({ + status: 'pending', + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + violations: expect.arrayContaining([expect.any(Object)]), + }); + + return true; + } catch (error) { + if (error instanceof Error) { + return error; + } + + // eslint-disable-next-line no-console + console.error(error); + return new Error('Unknown error'); + } + } + ), + + toHavePreparedSubmissionXML: new AsyncAsymmetricTypedExpectExtension( + assertSubmissionResult, + assertString, + async (actual, expected): Promise => { + const instanceFile = getSubmissionInstanceFile(actual); + const actualText = await getBlobText(instanceFile); - return comparableActual.equals(comparableExpected); + return compareSubmissionXML(actualText, expected); } ), }); From b3c193f63efce26af85c546448cb1239704a82b1 Mon Sep 17 00:00:00 2001 From: eyelidlessness Date: Thu, 26 Sep 2024 15:59:41 -0700 Subject: [PATCH 16/18] =?UTF-8?q?scenario:=20test=20prepared=20single-requ?= =?UTF-8?q?est=20submission=20(`SubmissionResult<=E2=80=98monolithic?= =?UTF-8?q?=E2=80=99>`)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/scenario/test/submission.test.ts | 205 ++++++++++++++++++++++ 1 file changed, 205 insertions(+) diff --git a/packages/scenario/test/submission.test.ts b/packages/scenario/test/submission.test.ts index 927af1009..edb70f879 100644 --- a/packages/scenario/test/submission.test.ts +++ b/packages/scenario/test/submission.test.ts @@ -14,8 +14,12 @@ import { t, title, } from '@getodk/common/test/fixtures/xform-dsl/index.ts'; +import { TagXFormsElement } from '@getodk/common/test/fixtures/xform-dsl/TagXFormsElement.ts'; +import type { XFormsElement } from '@getodk/common/test/fixtures/xform-dsl/XFormsElement.ts'; +import { createUniqueId } from 'solid-js'; import { beforeEach, describe, expect, it } from 'vitest'; import { Scenario } from '../src/jr/Scenario.ts'; +import { ANSWER_OK, ANSWER_REQUIRED_BUT_EMPTY } from '../src/jr/validation/ValidateOutcome.ts'; import { ReactiveScenario } from '../src/reactive/ReactiveScenario.ts'; describe('Form submission', () => { @@ -590,4 +594,205 @@ describe('Form submission', () => { }); }); }); + + describe('submission payload', () => { + const DEFAULT_INSTANCE_ID = 'uuid:TODO-mock-xpath-functions'; + + // prettier-ignore + type SubmissionFixtureElements = + | readonly [] + | readonly [XFormsElement]; + + interface BuildSubmissionPayloadScenario { + readonly submissionElements?: SubmissionFixtureElements; + } + + const buildSubmissionPayloadScenario = async ( + options?: BuildSubmissionPayloadScenario + ): Promise => { + const scenario = await Scenario.init( + 'Prepare for submission', + html( + head( + title('Prepare for submission'), + model( + mainInstance( + t( + 'data id="prepare-for-submission"', + t('rep', t('inp')), + t('meta', t('instanceID')) + ) + ), + ...(options?.submissionElements ?? []), + bind('/data/rep/inp').required(), + bind('/data/meta/instanceID').calculate(`'${DEFAULT_INSTANCE_ID}'`) + ) + ), + body(repeat('/data/rep', label('rep'), input('/data/rep/inp', label('inp')))) + ) + ); + + return scenario; + }; + + describe('submission definition', () => { + it('includes a default submission definition', async () => { + const scenario = await buildSubmissionPayloadScenario(); + const submissionResult = await scenario.prepareWebFormsSubmission(); + + expect(submissionResult.definition).toMatchObject({ + submissionAction: null, + submissionMethod: 'post', + encryptionKey: null, + }); + }); + + it('includes a form-specified submission definition URL', async () => { + const submissionAction = 'https://example.org'; + const scenario = await buildSubmissionPayloadScenario({ + submissionElements: [t(`submission action="${submissionAction}"`)], + }); + const submissionResult = await scenario.prepareWebFormsSubmission(); + + expect(submissionResult.definition).toMatchObject({ + submissionAction: new URL(submissionAction), + }); + }); + + it('accepts an explicit method="post" as post', async () => { + const scenario = await buildSubmissionPayloadScenario({ + submissionElements: [t('submission method="post"')], + }); + const submissionResult = await scenario.prepareWebFormsSubmission(); + + expect(submissionResult.definition).toMatchObject({ + submissionMethod: 'post', + }); + }); + + it('treats method="form-data-post" as method="post"', async () => { + const scenario = await buildSubmissionPayloadScenario({ + submissionElements: [t('submission method="form-data-post"')], + }); + const submissionResult = await scenario.prepareWebFormsSubmission(); + + expect(submissionResult.definition).toMatchObject({ + submissionMethod: 'post', + }); + }); + + it.each(['nope', 'not-this-either', 'poast'])( + 'fails to load when form specifies unsupported submission method', + async (otherMethod) => { + const init = async () => { + await buildSubmissionPayloadScenario({ + submissionElements: [t(`submission method="${otherMethod}"`)], + }); + }; + + await expect(init).rejects.toThrow(); + } + ); + + it('includes a form-specified `base64RsaPublicKey` as encryptionKey', async () => { + const base64RsaPublicKey = btoa(createUniqueId()); + const scenario = await buildSubmissionPayloadScenario({ + submissionElements: [ + // Note: `t()` fails here, presumably because the ported JavaRosa + // `parseAttributes` doesn't expect equals signs as produced in + // the trailing base64 value. + new TagXFormsElement( + 'submission', + new Map([['base64RsaPublicKey', base64RsaPublicKey]]), + [] + ), + ], + }); + const submissionResult = await scenario.prepareWebFormsSubmission(); + + expect(submissionResult.definition).toMatchObject({ + encryptionKey: base64RsaPublicKey, + }); + }); + }); + + describe('for a single (monolithic) request', () => { + describe('valid submission state', () => { + let scenario: Scenario; + let validSubmissionXML: string; + + beforeEach(async () => { + scenario = await buildSubmissionPayloadScenario(); + + scenario.answer('/data/rep[1]/inp', 'rep 1 inp'); + scenario.createNewRepeat('/data/rep'); + scenario.answer('/data/rep[2]/inp', 'rep 2 inp'); + + // Check assumption: form state is valid + expect(scenario.getValidationOutcome().outcome).toBe(ANSWER_OK); + + // prettier-ignore + validSubmissionXML = t('data', + t('rep', + t('inp', 'rep 1 inp')), + t('rep', + t('inp', 'rep 2 inp')), + t('meta', + t('instanceID', DEFAULT_INSTANCE_ID)) + ).asXml(); + }); + + it('is ready for submission when instance state is valid', async () => { + const submissionResult = await scenario.prepareWebFormsSubmission(); + + expect(submissionResult).toBeReadyForSubmission(); + }); + + it('includes submission instance XML file data', async () => { + const submissionResult = await scenario.prepareWebFormsSubmission(); + + await expect(submissionResult).toHavePreparedSubmissionXML(validSubmissionXML); + }); + }); + + describe('invalid submission state', () => { + let scenario: Scenario; + let invalidSubmissionXML: string; + + beforeEach(async () => { + scenario = await buildSubmissionPayloadScenario(); + + scenario.answer('/data/rep[1]/inp', 'rep 1 inp'); + scenario.createNewRepeat('/data/rep'); + + // Check assumption: form state is valid + expect(scenario.getValidationOutcome().outcome).toBe(ANSWER_REQUIRED_BUT_EMPTY); + + // prettier-ignore + invalidSubmissionXML = t('data', + t('rep', + t('inp', 'rep 1 inp')), + t('rep', + t('inp')), + t('meta', + t('instanceID', DEFAULT_INSTANCE_ID)) + ).asXml(); + }); + + it('is pending submission with violations', async () => { + const submissionResult = await scenario.prepareWebFormsSubmission(); + + expect(submissionResult).toBePendingSubmissionWithViolations(); + }); + + it('produces submission instance XML file data even when current instance state is invalid', async () => { + const submissionResult = await scenario.prepareWebFormsSubmission(); + + await expect(submissionResult).toHavePreparedSubmissionXML(invalidSubmissionXML); + }); + }); + }); + + describe.todo('for multiple requests, chunked by maximum size'); + }); }); From 832e762111c367b04ebceea7cff222fc70807618 Mon Sep 17 00:00:00 2001 From: eyelidlessness Date: Thu, 26 Sep 2024 16:04:19 -0700 Subject: [PATCH 17/18] engine: implementation of applicable aspects of `prepareSubmission` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Full support for `SubmissionResult<‘monolithic’>` - Minimal support for `SubmissionResult<‘chunked’>` Open questions: 1. Should we totally stub off the chunked option for now? I would normally say yes, as it’ll give us more feedback as we implement missing functionality. Hesitation is that the minimal support is already somewhat useful/usable until we support submission attachments. 2. Regardless of how extensively we support the chunked option now, do we have enough of the prerequisites in place to add stub test failures which will alert us when those tests need to be updated for more complete chunked submission support? --- packages/xforms-engine/src/instance/Root.ts | 22 ++- .../ClientReactiveSubmittableInstance.ts | 19 ++ .../ClientReactiveSubmittableLeafNode.ts | 5 + .../ClientReactiveSubmittableParentNode.ts | 3 +- .../submission/prepareSubmission.ts | 172 ++++++++++++++++++ 5 files changed, 215 insertions(+), 6 deletions(-) create mode 100644 packages/xforms-engine/src/instance/internal-api/submission/ClientReactiveSubmittableInstance.ts create mode 100644 packages/xforms-engine/src/lib/client-reactivity/submission/prepareSubmission.ts diff --git a/packages/xforms-engine/src/instance/Root.ts b/packages/xforms-engine/src/instance/Root.ts index bacb66db4..ed868fc69 100644 --- a/packages/xforms-engine/src/instance/Root.ts +++ b/packages/xforms-engine/src/instance/Root.ts @@ -4,6 +4,7 @@ import { createSignal } from 'solid-js'; import type { ActiveLanguage, FormLanguage, FormLanguages } from '../client/FormLanguage.ts'; import type { FormNodeID } from '../client/identity.ts'; import type { RootNode } from '../client/RootNode.ts'; +import type { SubmissionDefinition } from '../client/submission/SubmissionDefinition.ts'; import type { SubmissionChunkedType, SubmissionOptions, @@ -12,6 +13,7 @@ import type { SubmissionResult } from '../client/submission/SubmissionResult.ts' import type { SubmissionState } from '../client/submission/SubmissionState.ts'; import type { AncestorNodeValidationState } from '../client/validation.ts'; import { createParentNodeSubmissionState } from '../lib/client-reactivity/submission/createParentNodeSubmissionState.ts'; +import { prepareSubmission } from '../lib/client-reactivity/submission/prepareSubmission.ts'; import type { ChildrenState } from '../lib/reactivity/createChildrenState.ts'; import { createChildrenState } from '../lib/reactivity/createChildrenState.ts'; import type { MaterializedChildren } from '../lib/reactivity/materializeCurrentStateChildren.ts'; @@ -29,7 +31,7 @@ import { buildChildren } from './children.ts'; import type { GeneralChildNode } from './hierarchy.ts'; import type { EvaluationContext, EvaluationContextRoot } from './internal-api/EvaluationContext.ts'; import type { InstanceConfig } from './internal-api/InstanceConfig.ts'; -import type { ClientReactiveSubmittableParentNode } from './internal-api/submission/ClientReactiveSubmittableParentNode.ts'; +import type { ClientReactiveSubmittableInstance } from './internal-api/submission/ClientReactiveSubmittableInstance.ts'; import type { SubscribableDependency } from './internal-api/SubscribableDependency.ts'; import type { TranslationContext } from './internal-api/TranslationContext.ts'; @@ -108,7 +110,7 @@ export class Root EvaluationContextRoot, SubscribableDependency, TranslationContext, - ClientReactiveSubmittableParentNode + ClientReactiveSubmittableInstance { private readonly childrenState: ChildrenState; @@ -128,6 +130,11 @@ export class Root readonly validationState: AncestorNodeValidationState; readonly submissionState: SubmissionState; + // ClientReactiveSubmittableInstance + get submissionDefinition(): SubmissionDefinition { + return this.definition.submission; + } + protected readonly instanceDOM: XFormDOM; // BaseNode @@ -228,10 +235,15 @@ export class Root return this; } - prepareSubmission( - _options?: SubmissionOptions + prepareSubmission( + options?: SubmissionOptions ): Promise> { - throw new Error('Not implemented'); + const result = prepareSubmission(this, { + chunked: (options?.chunked ?? 'monolithic') as ChunkedType, + maxSize: options?.maxSize ?? Infinity, + }); + + return Promise.resolve(result); } // SubscribableDependency diff --git a/packages/xforms-engine/src/instance/internal-api/submission/ClientReactiveSubmittableInstance.ts b/packages/xforms-engine/src/instance/internal-api/submission/ClientReactiveSubmittableInstance.ts new file mode 100644 index 000000000..49f5ca63f --- /dev/null +++ b/packages/xforms-engine/src/instance/internal-api/submission/ClientReactiveSubmittableInstance.ts @@ -0,0 +1,19 @@ +import type { SubmissionDefinition } from '../../../client/submission/SubmissionDefinition.ts'; +import type { AncestorNodeValidationState } from '../../../client/validation.ts'; +import type { GeneralChildNode } from '../../hierarchy.ts'; +import type { + ClientReactiveSubmittableParentNode, + ClientReactiveSubmittableParentNodeDefinition, +} from './ClientReactiveSubmittableParentNode.ts'; + +interface ClientReactiveSubmittableInstanceDefinition + extends ClientReactiveSubmittableParentNodeDefinition { + readonly submission: SubmissionDefinition; +} + +export interface ClientReactiveSubmittableInstance + extends ClientReactiveSubmittableParentNode { + readonly definition: ClientReactiveSubmittableInstanceDefinition; + readonly parent: null; + readonly validationState: AncestorNodeValidationState; +} diff --git a/packages/xforms-engine/src/instance/internal-api/submission/ClientReactiveSubmittableLeafNode.ts b/packages/xforms-engine/src/instance/internal-api/submission/ClientReactiveSubmittableLeafNode.ts index eda668999..d224eb2b9 100644 --- a/packages/xforms-engine/src/instance/internal-api/submission/ClientReactiveSubmittableLeafNode.ts +++ b/packages/xforms-engine/src/instance/internal-api/submission/ClientReactiveSubmittableLeafNode.ts @@ -1,5 +1,9 @@ import type { SubmissionState } from '../../../client/submission/SubmissionState.ts'; import type { EscapedXMLText } from '../../../lib/xml-serialization.ts'; +import type { + ClientReactiveSubmittableChildNode, + ClientReactiveSubmittableParentNode, +} from './ClientReactiveSubmittableParentNode.ts'; interface ClientReactiveSubmittableLeafNodeCurrentState { get relevant(): boolean; @@ -14,6 +18,7 @@ interface ClientReactiveSubmittableLeafNodeDefinition { export interface ClientReactiveSubmittableLeafNode { readonly definition: ClientReactiveSubmittableLeafNodeDefinition; + readonly parent: ClientReactiveSubmittableParentNode; readonly currentState: ClientReactiveSubmittableLeafNodeCurrentState; /** diff --git a/packages/xforms-engine/src/instance/internal-api/submission/ClientReactiveSubmittableParentNode.ts b/packages/xforms-engine/src/instance/internal-api/submission/ClientReactiveSubmittableParentNode.ts index 3c2656120..aa64e5a74 100644 --- a/packages/xforms-engine/src/instance/internal-api/submission/ClientReactiveSubmittableParentNode.ts +++ b/packages/xforms-engine/src/instance/internal-api/submission/ClientReactiveSubmittableParentNode.ts @@ -11,7 +11,7 @@ interface ClientReactiveSubmittableParentNodeCurrentState< get children(): readonly Child[]; } -interface ClientReactiveSubmittableParentNodeDefinition { +export interface ClientReactiveSubmittableParentNodeDefinition { readonly nodeName: string; } @@ -19,6 +19,7 @@ export interface ClientReactiveSubmittableParentNode< Child extends ClientReactiveSubmittableChildNode, > { readonly definition: ClientReactiveSubmittableParentNodeDefinition; + readonly parent: ClientReactiveSubmittableParentNode | null; readonly currentState: ClientReactiveSubmittableParentNodeCurrentState; readonly submissionState: SubmissionState; } diff --git a/packages/xforms-engine/src/lib/client-reactivity/submission/prepareSubmission.ts b/packages/xforms-engine/src/lib/client-reactivity/submission/prepareSubmission.ts new file mode 100644 index 000000000..6a72075f6 --- /dev/null +++ b/packages/xforms-engine/src/lib/client-reactivity/submission/prepareSubmission.ts @@ -0,0 +1,172 @@ +import { UnreachableError } from '@getodk/common/lib/error/UnreachableError.ts'; +import { + SUBMISSION_INSTANCE_FILE_NAME, + SUBMISSION_INSTANCE_FILE_TYPE, +} from '../../../client/constants.ts'; +import type { SubmissionData } from '../../../client/submission/SubmissionData.ts'; +import type { SubmissionDefinition } from '../../../client/submission/SubmissionDefinition.ts'; +import type { SubmissionInstanceFile } from '../../../client/submission/SubmissionInstanceFile.ts'; +import type { SubmissionChunkedType } from '../../../client/submission/SubmissionOptions.ts'; +import type { SubmissionResult } from '../../../client/submission/SubmissionResult.ts'; +import type { DescendantNodeViolationReference } from '../../../client/validation.ts'; +import type { ClientReactiveSubmittableInstance } from '../../../instance/internal-api/submission/ClientReactiveSubmittableInstance.ts'; + +class InstanceFile extends File implements SubmissionInstanceFile { + override readonly name = SUBMISSION_INSTANCE_FILE_NAME; + override readonly type = SUBMISSION_INSTANCE_FILE_TYPE; + + constructor(instanceRoot: ClientReactiveSubmittableInstance) { + const { submissionXML } = instanceRoot.submissionState; + + super([submissionXML], SUBMISSION_INSTANCE_FILE_NAME, { + type: SUBMISSION_INSTANCE_FILE_TYPE, + }); + } +} + +type AssertSubmissionData = (data: FormData) => asserts data is SubmissionData; + +const assertSubmissionData: AssertSubmissionData = (data) => { + const instanceFile = data.get(SUBMISSION_INSTANCE_FILE_NAME); + + if (!(instanceFile instanceof InstanceFile)) { + throw new Error(`Invalid SubmissionData`); + } +}; + +class InstanceSubmissionData extends FormData { + static from(instanceFile: InstanceFile, attachments: readonly File[]): SubmissionData { + const data = new this(instanceFile, attachments); + + assertSubmissionData(data); + + return data; + } + + private constructor( + readonly instanceFile: InstanceFile, + readonly attachments: readonly File[] + ) { + super(); + + this.set(SUBMISSION_INSTANCE_FILE_NAME, instanceFile); + + attachments.forEach((attachment) => { + const { name } = attachment; + + if (name === SUBMISSION_INSTANCE_FILE_NAME && attachment !== instanceFile) { + throw new Error( + `Failed to add conflicting attachment with name ${SUBMISSION_INSTANCE_FILE_NAME}` + ); + } + + this.set(name, attachment); + }); + } +} + +interface PendingValidation { + readonly status: 'pending'; + readonly violations: readonly DescendantNodeViolationReference[]; +} + +interface ReadyValidation { + readonly status: 'ready'; + readonly violations: null; +} + +type SubmissionInstanceStateValidation = PendingValidation | ReadyValidation; + +const validateSubmission = ( + instanceRoot: ClientReactiveSubmittableInstance +): SubmissionInstanceStateValidation => { + const { violations } = instanceRoot.validationState; + + if (violations.length === 0) { + return { + status: 'ready', + violations: null, + }; + } + + return { + status: 'pending', + violations, + }; +}; + +const monolithicSubmissionResult = ( + validation: SubmissionInstanceStateValidation, + definition: SubmissionDefinition, + instanceFile: InstanceFile, + attachments: readonly File[] +): SubmissionResult<'monolithic'> => { + const data = InstanceSubmissionData.from(instanceFile, attachments); + + return { + ...validation, + definition, + data, + }; +}; + +interface ChunkedSubmissionResultOptions { + readonly maxSize: number; +} + +const chunkedSubmissionResult = ( + validation: SubmissionInstanceStateValidation, + definition: SubmissionDefinition, + instanceFile: InstanceFile, + attachments: readonly File[], + options: ChunkedSubmissionResultOptions +): SubmissionResult<'chunked'> => { + if (attachments.length > 0 || options.maxSize !== Infinity) { + throw new Error('Submission chunking pending implementation'); + } + + const data = InstanceSubmissionData.from(instanceFile, attachments); + + return { + ...validation, + definition, + data: [data], + }; +}; + +export interface PrepeareSubmissionOptions { + readonly chunked: ChunkedType; + readonly maxSize: number; +} + +export const prepareSubmission = ( + instanceRoot: ClientReactiveSubmittableInstance, + options: PrepeareSubmissionOptions +): SubmissionResult => { + const validation = validateSubmission(instanceRoot); + const definition = instanceRoot.definition.submission; + const instanceFile = new InstanceFile(instanceRoot); + const attachments: readonly File[] = []; + + switch (options.chunked) { + case 'chunked': + return chunkedSubmissionResult( + validation, + definition, + instanceFile, + attachments, + options + ) satisfies SubmissionResult<'chunked'> as SubmissionResult; + + case 'monolithic': + return monolithicSubmissionResult( + validation, + definition, + instanceFile, + attachments + ) satisfies SubmissionResult<'monolithic'> as SubmissionResult; + + default: + throw new UnreachableError(options.chunked); + } +}; From 8edf3755aa3ce289b11397f9ae323d97bfd6ba95 Mon Sep 17 00:00:00 2001 From: eyelidlessness Date: Fri, 27 Sep 2024 12:02:54 -0700 Subject: [PATCH 18/18] Changeset --- .changeset/yellow-tomatoes-check.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/yellow-tomatoes-check.md diff --git a/.changeset/yellow-tomatoes-check.md b/.changeset/yellow-tomatoes-check.md new file mode 100644 index 000000000..7716fd26f --- /dev/null +++ b/.changeset/yellow-tomatoes-check.md @@ -0,0 +1,8 @@ +--- +'@getodk/xforms-engine': minor +'@getodk/scenario': minor +'@getodk/ui-solid': patch +'@getodk/common': patch +--- + +Initial engine support for preparing submissions