Skip to content

Commit

Permalink
Initial engine/client interface: submission
Browse files Browse the repository at this point in the history
This is essentially the design originally proposed in #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`
  • Loading branch information
eyelidlessness committed Sep 26, 2024
1 parent 63675ef commit 8cfdb29
Show file tree
Hide file tree
Showing 21 changed files with 299 additions and 43 deletions.
3 changes: 1 addition & 2 deletions packages/xforms-engine/src/client/BaseNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -129,8 +130,6 @@ export interface BaseNodeState {
get value(): unknown;
}

type FormNodeID = string;

/**
* Base interface for common/shared aspects of any node type.
*/
Expand Down
33 changes: 33 additions & 0 deletions packages/xforms-engine/src/client/RootNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<ChunkedType extends SubmissionChunkedType>(
options?: SubmissionOptions<ChunkedType>
): Promise<SubmissionResult<ChunkedType>>;
}
6 changes: 6 additions & 0 deletions packages/xforms-engine/src/client/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,9 @@ export const VALIDATION_TEXT = {
type ValidationTextDefaults = typeof VALIDATION_TEXT;

export type ValidationTextDefault<Role extends ValidationTextRole> = 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;
16 changes: 16 additions & 0 deletions packages/xforms-engine/src/client/identity.ts
Original file line number Diff line number Diff line change
@@ -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}`;
12 changes: 12 additions & 0 deletions packages/xforms-engine/src/client/submission/SubmissionData.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
28 changes: 28 additions & 0 deletions packages/xforms-engine/src/client/submission/SubmissionOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export type SubmissionChunkedType = 'chunked' | 'monolithic';

interface BaseSubmissionOptions<ChunkedType extends SubmissionChunkedType> {
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<ChunkedType extends SubmissionChunkedType = 'monolithic'> = {
chunked: ChunkedSubmissionOptions;
monolithic: MonolithicSubmissionOptions;
}[ChunkedType];
124 changes: 124 additions & 0 deletions packages/xforms-engine/src/client/submission/SubmissionResult.ts
Original file line number Diff line number Diff line change
@@ -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<ChunkedType extends SubmissionChunkedType> = {
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<ChunkedType extends SubmissionChunkedType> {
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<ChunkedType>;
}

interface PendingSubmissionResult<ChunkedType extends SubmissionChunkedType>
extends BaseSubmissionResult<ChunkedType> {
readonly status: 'pending';
get violations(): readonly DescendantNodeViolationReference[];
}

interface MaxSizeExceededResult extends BaseSubmissionResult<'chunked'> {
readonly status: 'max-size-exceeded';
get violations(): readonly MaxSizeViolation[];
}

interface ReadySubmissionResult<ChunkedType extends SubmissionChunkedType>
extends BaseSubmissionResult<ChunkedType> {
readonly status: 'ready';
get violations(): null;
}

// prettier-ignore
type CommonSubmissionResult<ChunkedType extends SubmissionChunkedType> =
| PendingSubmissionResult<ChunkedType>
| ReadySubmissionResult<ChunkedType>;

// prettier-ignore
export type ChunkedSubmissionResult =
| CommonSubmissionResult<'chunked'>
| MaxSizeExceededResult;

export type MonolithicSubmissionResult = CommonSubmissionResult<'monolithic'>;

// prettier-ignore
export type SubmissionResult<ChunkedType extends SubmissionChunkedType> = {
chunked: ChunkedSubmissionResult;
monolithic: MonolithicSubmissionResult;
}[ChunkedType];
4 changes: 2 additions & 2 deletions packages/xforms-engine/src/client/validation.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions packages/xforms-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
4 changes: 2 additions & 2 deletions packages/xforms-engine/src/instance/Group.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -16,15 +17,14 @@ 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';

// prettier-ignore
interface GroupStateSpec extends DescendantNodeSharedStateSpec {
readonly label: Accessor<TextRange<'label'> | null>;
readonly hint: null;
readonly children: Accessor<readonly NodeID[]>;
readonly children: Accessor<readonly FormNodeID[]>;
readonly valueOptions: null;
readonly value: null;
}
Expand Down
15 changes: 13 additions & 2 deletions packages/xforms-engine/src/instance/Root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -32,7 +37,7 @@ interface RootStateSpec {
readonly required: boolean;
readonly label: null;
readonly hint: null;
readonly children: Accessor<readonly NodeID[]>;
readonly children: Accessor<readonly FormNodeID[]>;
readonly valueOptions: null;
readonly value: null;

Expand Down Expand Up @@ -217,6 +222,12 @@ export class Root
return this;
}

prepareSubmission<ChunkedType extends SubmissionChunkedType>(
_options?: SubmissionOptions<ChunkedType>
): Promise<SubmissionResult<ChunkedType>> {
throw new Error('Not implemented');
}

// SubscribableDependency
override subscribe(): void {
super.subscribe();
Expand Down
Loading

0 comments on commit 8cfdb29

Please sign in to comment.