Skip to content

Commit

Permalink
Initial engine/client API for constraint/required validation
Browse files Browse the repository at this point in the history
As discussed in #140

Based on 4c38a22, with some refinements:

- Each condition’s validity state is now represented by `valid: boolean`
- Minor naming adjustments
- Nailed down an initial pair of message constants for engine-fallback messages
- Added a method to check whether a message is an engine-fallback, in a way that allows type narrowing and produces those message constants as types
- Added a ton of JSDoc documentation clarifying the design, expected usage, some anticipated performance caveats, etc
  • Loading branch information
eyelidlessness committed Jul 1, 2024
1 parent b44beec commit 15d9f82
Show file tree
Hide file tree
Showing 12 changed files with 400 additions and 10 deletions.
80 changes: 73 additions & 7 deletions packages/xforms-engine/src/client/BaseNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import type { NodeAppearances } from './NodeAppearances.ts';
import type { OpaqueReactiveObjectFactory } from './OpaqueReactiveObjectFactory.ts';
import type { TextRange } from './TextRange.ts';
import type { InstanceNodeType } from './node-types.ts';
import type {
AncestorNodeValidationState,
LeafNodeValidationState,
NodeValidationState,
} from './validation.ts';

export interface BaseNodeState {
/**
Expand Down Expand Up @@ -43,11 +48,17 @@ export interface BaseNodeState {
*/
get relevant(): boolean;

// Note: according to spec, `required` is NOT inherited from ancestor nodes.
// What this means for a `required` state on subtree nodes is an open
// question. It was also raised on the first engine-internals iteration, and I
// could have sworn it was discussed in that PR, but finding any record of
// this discussion has proven elusive.
/**
* Specifies whether the node must have a non-blank value to be valid (see
* {@link value} for details).
*
* @see {@link https://getodk.github.io/xforms-spec/#bind-attributes}
*
* @default false
*
* @todo What is the expected behavior of `required` expressions defined for
* non-leaf/value nodes?
*/
get required(): boolean;

/**
Expand Down Expand Up @@ -106,6 +117,14 @@ export interface BaseNodeState {
*
* Parent nodes, i.e. nodes which can contain {@link children}, do not store a
* value state. For those nodes, their value state should always be `null`.
*
* A node's value is considered "blank" when its primary instance state is an
* empty string, and it is considered "non-blank" otherwise. The engine may
* represent node values according to aspects of the node's definition (such
* as its defined data type, its associated control type if any). The node's
* value being blank or non-blank may contribute to satisfying conditions of
* the node's validity ({@link constraint}, {@link required}). Otherwise, it
* is an internal engine consideration.
*/
get value(): unknown;
}
Expand Down Expand Up @@ -181,8 +200,55 @@ export interface BaseNode {
readonly parent: BaseNode | null;

/**
* Each node provides a discrete object representing the stateful aspects of
* that node which will change over time. When a client provides a {@link OpaqueReactiveObjectFactory}
* Each node provides a discrete object representing the stateful aspects\* of
* that node which will change over time. When a client provides a
* {@link OpaqueReactiveObjectFactory}, the engine will update the properties
* of this object as their respective states change, so a client can implement
* reactive updates that respond to changes as they occur.
*
* \* This includes state which is either client-/user-mutable, or state which
* is computed based on the core XForms computation model. Each node also
* exposes {@link validationState}, which reflects the validity of the
* node, or its descendants.
*/
readonly currentState: BaseNodeState;

/**
* Represents the validation state of a the node itself, or its descendants.
*
* @see {@link AncestorNodeValidationState} and
* {@link LeafNodeValidationState} for additional details.
*
* While filling a form (i.e. prior to submission), validation state can be
* viewed as computed metadata about the form state. The validation conditions
* and their violation messages produced by a node _may be computed on
* demand_. Clients should assume:
*
* 1. Validation state **will be current** when directly read by the client.
* Accessing validation state _may_ invoke engine computation of that state
* _at that time_.
*
* It **may** also be pre-computed by the engine so that direct reads are
* less computationally expensive, but such optimizations cannot be
* guaranteed by the engine at this time.
*
* 2. For clients providing an {@link OpaqueReactiveObjectFactory}, accessing
* validation state within a reactive context **will produce updates** to
* the validation state, as long as the client retains a subscription to
* that state.
*
* If it is possible to detect interruption of such client- reactive
* subscriptions, the engine _may defer computations_ until subsequent
* client read/re-subscription, in order to reduce unnecessary
* computational overhead. Again, such optimizations cannot be guaranteed
* by the engine at this time.
*
* @todo it's easier to conceive a reliable, general solution to optimizing
* the direct read case, than it is for the client-reactive case (largely
* because our solution for client reactivity is intentionally opaque). If it
* turns out that such optimizations are crucial for overall usability, the
* client-reactive case may best be served by additional APIs for reactive
* clients to explicitly pause and resume recomputation.
*/
readonly validationState: NodeValidationState;
}
2 changes: 2 additions & 0 deletions packages/xforms-engine/src/client/GroupNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { BaseNode, BaseNodeState } from './BaseNode.ts';
import type { NodeAppearances } from './NodeAppearances.ts';
import type { RootNode } from './RootNode.ts';
import type { GeneralChildNode, GeneralParentNode } from './hierarchy.ts';
import type { AncestorNodeValidationState } from './validation.ts';

export interface GroupNodeState extends BaseNodeState {
get hint(): null;
Expand Down Expand Up @@ -34,4 +35,5 @@ export interface GroupNode extends BaseNode {
readonly root: RootNode;
readonly parent: GeneralParentNode;
readonly currentState: GroupNodeState;
readonly validationState: AncestorNodeValidationState;
}
2 changes: 2 additions & 0 deletions packages/xforms-engine/src/client/RepeatInstanceNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { NodeAppearances } from './NodeAppearances.ts';
import type { RepeatRangeNode } from './RepeatRangeNode.ts';
import type { RootNode } from './RootNode.ts';
import type { GeneralChildNode } from './hierarchy.ts';
import type { AncestorNodeValidationState } from './validation.ts';

export interface RepeatInstanceNodeState extends BaseNodeState {
// TODO(?): Previous iteration included an `index` getter here. I don't see it
Expand Down Expand Up @@ -42,4 +43,5 @@ export interface RepeatInstanceNode extends BaseNode {
readonly parent: RepeatRangeNode;

readonly currentState: RepeatInstanceNodeState;
readonly validationState: AncestorNodeValidationState;
}
2 changes: 2 additions & 0 deletions packages/xforms-engine/src/client/RepeatRangeNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { RepeatInstanceNode } from './RepeatInstanceNode.ts';
import type { RootNode } from './RootNode.ts';
import type { TextRange } from './TextRange.ts';
import type { GeneralParentNode } from './hierarchy.ts';
import type { AncestorNodeValidationState } from './validation.ts';

export interface RepeatRangeNodeState extends BaseNodeState {
get hint(): null;
Expand Down Expand Up @@ -97,6 +98,7 @@ export interface RepeatRangeNode extends BaseNode {
readonly root: RootNode;
readonly parent: GeneralParentNode;
readonly currentState: RepeatRangeNodeState;
readonly validationState: AncestorNodeValidationState;

addInstances(afterIndex?: number, count?: number): RootNode;

Expand Down
2 changes: 2 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,7 @@ import type { RootDefinition } from '../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 { AncestorNodeValidationState } from './validation.ts';

export interface RootNodeState extends BaseNodeState {
/**
Expand Down Expand Up @@ -47,6 +48,7 @@ export interface RootNode extends BaseNode {
readonly root: RootNode;
readonly parent: null;
readonly currentState: RootNodeState;
readonly validationState: AncestorNodeValidationState;

/**
* @todo as with {@link RootNodeState.activeLanguage}, this is the most
Expand Down
4 changes: 3 additions & 1 deletion packages/xforms-engine/src/client/SelectNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ import type { RootNode } from './RootNode.ts';
import type { StringNode } from './StringNode.ts';
import type { TextRange } from './TextRange.ts';
import type { GeneralParentNode } from './hierarchy.ts';
import type { LeafNodeValidationState } from './validation.ts';

export interface SelectItem {
get value(): string;
get label(): TextRange<'label'> | null;
get label(): TextRange<'item-label'> | null;
}

export interface SelectNodeState extends BaseNodeState {
Expand Down Expand Up @@ -48,6 +49,7 @@ export interface SelectNode extends BaseNode {
readonly root: RootNode;
readonly parent: GeneralParentNode;
readonly currentState: SelectNodeState;
readonly validationState: LeafNodeValidationState;

/**
* For use by a client to update the selection of a select node where:
Expand Down
2 changes: 2 additions & 0 deletions packages/xforms-engine/src/client/StringNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { BaseNode, BaseNodeState } from './BaseNode.ts';
import type { NodeAppearances } from './NodeAppearances.ts';
import type { RootNode } from './RootNode.ts';
import type { GeneralParentNode } from './hierarchy.ts';
import type { LeafNodeValidationState } from './validation.ts';

export interface StringNodeState extends BaseNodeState {
get children(): null;
Expand Down Expand Up @@ -38,6 +39,7 @@ export interface StringNode extends BaseNode {
readonly root: RootNode;
readonly parent: GeneralParentNode;
readonly currentState: StringNodeState;
readonly validationState: LeafNodeValidationState;

/**
* For use by a client to update the value of a string node.
Expand Down
2 changes: 2 additions & 0 deletions packages/xforms-engine/src/client/SubtreeNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { SubtreeDefinition as BaseSubtreeDefinition } from '../model/Subtre
import type { BaseNode, BaseNodeState } from './BaseNode.ts';
import type { RootNode } from './RootNode.ts';
import type { GeneralChildNode, GeneralParentNode } from './hierarchy.ts';
import type { AncestorNodeValidationState } from './validation.ts';

export interface SubtreeNodeState extends BaseNodeState {
get label(): null;
Expand Down Expand Up @@ -55,4 +56,5 @@ export interface SubtreeNode extends BaseNode {
readonly root: RootNode;
readonly parent: GeneralParentNode;
readonly currentState: SubtreeNodeState;
readonly validationState: AncestorNodeValidationState;
}
101 changes: 99 additions & 2 deletions packages/xforms-engine/src/client/TextRange.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,70 @@
import type { ActiveLanguage } from './FormLanguage.ts';
import type { RootNodeState } from './RootNode.ts';

export type TextChunkSource = 'itext' | 'output' | 'static';
/**
* **COMMENTARY**
*
* The spec makes naming and mapping these cases a bit more complex than would
* be ideal. The intent is to clearly identify distinct text definitions (and
* sub-structural parts) from a source form, in a way that semantically lines up
* with the ways they will need to be handled at runtime and conveyed to
* clients. This is the mapping:
*
* - 'output': All output values, i.e.:
* - `output/@value`
*
* - 'translation':
*
* - Valid XPath translation expressions, in a context accepting mixed
* translation/static syntax, i.e.:
*
* - `h:head//bind/@jr:constraintMsg[is-translation-expr()]`
* - `h:head//bind/@jr:requiredMsg[is-translation-expr()]`
*
* Here, `is-translation-expr()` is a fictional shorthand for checking
* that the attribute's value is a valid `jr:itext(...)` FunctionCall
* expression. Note that, per spec, these attributes **do not accept
* arbitrary XPath expressions**! The non-translation case is treated as
* static text, not parsed for e.g. an XPath [string] Literal expression.
* This is why we have introduced this 'translation' case, distinct from
* 'reference', which previously handled translated labels and hints.
*
* - Valid XPath translation expressions, in a context accepting arbitrary
* XPath expressions, i.e.:
*
* - `h:body//label/@ref[is-translation-expr()]`
*
* - 'static':
* - `h:head//bind/@jr:constraintMsg[not(is-translation-expr())]`
* - `h:head//bind/@jr:requiredMsg[not(is-translation-expr())]`
* - `h:body//label/text()`
* - `h:body//hint/text()`
*
* (See notes above for clarification of `is-translation-expr()`.)
*
* - 'reference': Any XPath **non-translation** expression defined as a label's
* (or hint's) `ref` attribute, i.e.
* - `h:body//label/@ref[not(is-translation-expr())]`
* - `h:body//hint/@ref[not(is-translation-expr())]`
*
* (See notes above for clarification of `is-translation-expr()`.)
*
* @todo It's unclear whether this will all become simpler or more compelex when
* we add support for outputs in translations. In theory, the actual translation
* `<text>` nodes map quite well to the `TextRange` concept (i.e. they are a
* range of static and output chunks, just like labels and hints). The potential
* for complications arise from XPath implementation details being largely
* opaque (as in, the `jr:itext` implementation is encapsulated in the `xpath`
* package, and the engine doesn't really deal with itext translations at the
* node level at all).
*/
// prettier-ignore
export type TextChunkSource =
// eslint-disable-next-line @typescript-eslint/sort-type-constituents
| 'output'
| 'reference'
| 'translation'
| 'static';

/**
* @todo This (and everything else to do with {@link TextRange}s is for
Expand All @@ -23,6 +86,39 @@ export interface TextChunk {
get formatted(): unknown;
}

// eslint-disable-next-line @typescript-eslint/sort-type-constituents
export type ElementTextRole = 'hint' | 'label' | 'item-label';
export type ValidationTextRole = 'constraintMsg' | 'requiredMsg';
export type TextRole = ElementTextRole | ValidationTextRole;

/**
* Specifies the origin of a {@link TextRange}.
*
* - 'form': text is computed from the form definition, as specified for the
* {@link TextRole}. User-facing clients should present text with this origin
* where appropriate.
*
* - 'form-derived': the form definition lacks a text definition for the
* {@link TextRole}, but an appropriate one has been derived from a related
* (and semantically appropriate) aspect of the form (example: a select item
* without a label may derive that label from the item's value). User-facing
* clients should generally present text with this origin where provided; this
* origin clarifies the source of such text.
*
* - 'engine': the form definition lacks a definition for the {@link TextRole},
* but provides a constant default in its absence. User facing clients may
* disregard these constant text values, or may use them where a sensible
* default is desired. Clients may also use these constants as keys for
* translation purposes, as appropriate. Non-user facing clients may reference
* these constants for e.g. testing purposes.
*/
// prettier-ignore
export type TextOrigin =
// eslint-disable-next-line @typescript-eslint/sort-type-constituents
| 'form'
| 'form-derived'
| 'engine';

/**
* Represents aspects of a form which produce text, which _might_ be:
*
Expand Down Expand Up @@ -53,7 +149,8 @@ export interface TextChunk {
* a text range's role may correspond to the "short" or "guidance" `form` of a
* {@link https://getodk.github.io/xforms-spec/#languages | translation}).
*/
export interface TextRange<Role extends string | null = null> {
export interface TextRange<Role extends TextRole, Origin extends TextOrigin = TextOrigin> {
readonly origin: Origin;
readonly role: Role;

[Symbol.iterator](): Iterable<TextChunk>;
Expand Down
10 changes: 10 additions & 0 deletions packages/xforms-engine/src/client/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { ValidationTextRole } from './TextRange.ts';

export const VALIDATION_TEXT = {
constraintMsg: 'Condition not satisfied: constraint',
requiredMsg: 'Condition not satisfied: required',
} as const satisfies Record<ValidationTextRole, string>;

type ValidationTextDefaults = typeof VALIDATION_TEXT;

export type ValidationTextDefault<Role extends ValidationTextRole> = ValidationTextDefaults[Role];
Loading

0 comments on commit 15d9f82

Please sign in to comment.