diff --git a/.changeset/tasty-hornets-fix.md b/.changeset/tasty-hornets-fix.md new file mode 100644 index 00000000..1039a0c1 --- /dev/null +++ b/.changeset/tasty-hornets-fix.md @@ -0,0 +1,8 @@ +--- +"@getodk/common": minor +"@getodk/scenario": minor +"@getodk/web-forms": minor +"@getodk/xforms-engine": minor +--- + +Engine support for `constraint`, `required` validation diff --git a/packages/common/src/test/assertions/helpers.ts b/packages/common/src/test/assertions/helpers.ts index e9a9cb01..3482f375 100644 --- a/packages/common/src/test/assertions/helpers.ts +++ b/packages/common/src/test/assertions/helpers.ts @@ -1,6 +1,7 @@ export { instanceArrayAssertion } from './instanceArrayAssertion.ts'; 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'; diff --git a/packages/common/src/test/assertions/typeofAssertion.ts b/packages/common/src/test/assertions/typeofAssertion.ts index e938d85f..af361bec 100644 --- a/packages/common/src/test/assertions/typeofAssertion.ts +++ b/packages/common/src/test/assertions/typeofAssertion.ts @@ -61,7 +61,7 @@ interface TypeofTypes { type TypeofType = TypeofTypes[T]; -type TypeofAssertion = ( +export type TypeofAssertion = ( value: U ) => asserts value is Extract, U>; diff --git a/packages/common/src/test/assertions/vitest/ArbitraryConditionExpectExtension.ts b/packages/common/src/test/assertions/vitest/ArbitraryConditionExpectExtension.ts new file mode 100644 index 00000000..bf4335a4 --- /dev/null +++ b/packages/common/src/test/assertions/vitest/ArbitraryConditionExpectExtension.ts @@ -0,0 +1,23 @@ +import type { SyncExpectationResult } from 'vitest'; +import type { AssertIs } from '../../../../types/assertions/AssertIs.ts'; +import { assertVoidExpectedArgument } from './assertVoidExpectedArgument.ts'; +import { expandSimpleExpectExtensionResult } from './expandSimpleExpectExtensionResult.ts'; +import type { ExpectExtensionMethod } from './shared-extension-types.ts'; +import { validatedExtensionMethod } from './validatedExtensionMethod.ts'; + +export class ArbitraryConditionExpectExtension { + readonly extensionMethod: ExpectExtensionMethod; + + constructor( + readonly validateArgument: AssertIs, + readonly arbitraryCondition: ExpectExtensionMethod + ) { + const validatedMethod = validatedExtensionMethod( + validateArgument, + assertVoidExpectedArgument, + arbitraryCondition + ); + + this.extensionMethod = expandSimpleExpectExtensionResult(validatedMethod); + } +} 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 3165e30f..7e6ea42f 100644 --- a/packages/common/src/test/assertions/vitest/shared-extension-types.ts +++ b/packages/common/src/test/assertions/vitest/shared-extension-types.ts @@ -1,6 +1,7 @@ import type { SyncExpectationResult } from 'vitest'; 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 { StaticConditionExpectExtension } from './StaticConditionExpectExtension.ts'; import type { SymmetricTypedExpectExtension } from './SymmetricTypedExpectExtension.ts'; @@ -67,7 +68,10 @@ export type DeriveStaticVitestExpectExtension< > = { [K in keyof Implementation]: // eslint-disable-next-line @typescript-eslint/no-explicit-any - Implementation[K] extends StaticConditionExpectExtension + Implementation[K] extends ArbitraryConditionExpectExtension + ? () => VitestParameterizedReturn + // eslint-disable-next-line @typescript-eslint/no-explicit-any + : Implementation[K] extends StaticConditionExpectExtension ? () => VitestParameterizedReturn // eslint-disable-next-line @typescript-eslint/no-explicit-any : Implementation[K] extends TypedExpectExtension diff --git a/packages/scenario/resources/ImageSelectTester-alt.xml b/packages/scenario/resources/ImageSelectTester-alt.xml new file mode 100644 index 00000000..ed646944 --- /dev/null +++ b/packages/scenario/resources/ImageSelectTester-alt.xml @@ -0,0 +1,83 @@ + + + RichMedia testing Images + + + + + + + + + + + + + + + + + + + + Patient ID + ID + jr://audio/hah.mp3 + + + Full Name + Name + jr://images/four.gif + + + Please find the mirc icon + MircIcon + + + jr://images/four.gif + Icon 4 + AltText + + + jr://images/three.gif + Icon 3 + AltText + + + jr://images/two.gif + Icon 2 + AltText + + + jr://images/one.gif + Icon 1 + AltText + + + Should Be Less than 10 + + + + + + + + + + diff --git a/packages/scenario/src/answer/ValueNodeAnswer.ts b/packages/scenario/src/answer/ValueNodeAnswer.ts index f894c997..b076fe3b 100644 --- a/packages/scenario/src/answer/ValueNodeAnswer.ts +++ b/packages/scenario/src/answer/ValueNodeAnswer.ts @@ -1,6 +1,8 @@ import type { AnyLeafNode as ValueNode } from '@getodk/xforms-engine'; import { ComparableAnswer } from './ComparableAnswer.ts'; +export type { ValueNode }; + export abstract class ValueNodeAnswer extends ComparableAnswer { constructor(readonly node: Node) { super(); diff --git a/packages/scenario/src/assertion/extensions/answers.ts b/packages/scenario/src/assertion/extensions/answers.ts index 92fd049a..ff52281b 100644 --- a/packages/scenario/src/assertion/extensions/answers.ts +++ b/packages/scenario/src/assertion/extensions/answers.ts @@ -1,20 +1,26 @@ +import { UnreachableError } from '@getodk/common/lib/error/UnreachableError.ts'; import type { DeriveStaticVitestExpectExtension } from '@getodk/common/test/assertions/helpers.ts'; import { AsymmetricTypedExpectExtension, InspectableComparisonError, + StaticConditionExpectExtension, SymmetricTypedExpectExtension, extendExpect, instanceAssertion, } from '@getodk/common/test/assertions/helpers.ts'; +import { constants, type ValidationCondition } from '@getodk/xforms-engine'; import { expect } from 'vitest'; import { ComparableAnswer } from '../../answer/ComparableAnswer.ts'; import { ExpectedApproximateUOMAnswer } from '../../answer/ExpectedApproximateUOMAnswer.ts'; +import type { ValueNode } from '../../answer/ValueNodeAnswer.ts'; +import { ValueNodeAnswer } from '../../answer/ValueNodeAnswer.ts'; import { AnswerResult } from '../../jr/Scenario.ts'; -import { ValidationImplementationPendingError } from '../../jr/validation/ValidationImplementationPendingError.ts'; -import { assertString } from './shared-type-assertions.ts'; +import { assertNullableString, assertString } from './shared-type-assertions.ts'; const assertComparableAnswer = instanceAssertion(ComparableAnswer); +const assertValueNodeAnswer = instanceAssertion>(ValueNodeAnswer); + const assertExpectedApproximateUOMAnswer = instanceAssertion(ExpectedApproximateUOMAnswer); type AssertAnswerResult = (value: unknown) => asserts value is AnswerResult; @@ -29,6 +35,31 @@ const assertAnswerResult: AssertAnswerResult = (value) => { } }; +const matchDefaultMessage = (condition: ValidationCondition) => { + const expectedMessage = constants.VALIDATION_TEXT[`${condition}Msg`]; + + return { + node: { + validationState: { + [condition]: { + valid: false, + message: { + origin: 'engine', + asString: expectedMessage, + }, + }, + violation: { + condition, + message: { + origin: 'engine', + asString: expectedMessage, + }, + }, + }, + }, + }; +}; + const answerExtensions = extendExpect(expect, { toEqualAnswer: new SymmetricTypedExpectExtension(assertComparableAnswer, (actual, expected) => { const pass = actual.stringValue === expected.stringValue; @@ -64,13 +95,76 @@ const answerExtensions = extendExpect(expect, { * the spec. */ toHaveValidityStatus: new AsymmetricTypedExpectExtension( - assertComparableAnswer, + assertValueNodeAnswer, assertAnswerResult, - (_actual, _expected) => { - return new ValidationImplementationPendingError(); + (actual, expected) => { + const { condition } = actual.node.validationState.violation ?? {}; + let pass: boolean; + + switch (expected) { + case AnswerResult.CONSTRAINT_VIOLATED: + pass = condition === 'constraint'; + break; + + case AnswerResult.REQUIRED_BUT_EMPTY: + pass = condition === 'required'; + break; + + case AnswerResult.OK: + pass = condition == null; + break; + + default: + return new UnreachableError(expected); + } + + return pass || new InspectableComparisonError(actual, expected, 'be'); + } + ), + + toHaveConstraintMessage: new AsymmetricTypedExpectExtension( + assertValueNodeAnswer, + assertNullableString, + (actual, expected) => { + const { asString = null } = actual.node.validationState.constraint?.message ?? {}; + const pass = asString === expected; + + return pass || new InspectableComparisonError(asString, expected, 'to be message'); + } + ), + + toHaveRequiredMessage: new AsymmetricTypedExpectExtension( + assertValueNodeAnswer, + assertNullableString, + (actual, expected) => { + const { asString = null } = actual.node.validationState.required?.message ?? {}; + const pass = asString === expected; + + return pass || new InspectableComparisonError(asString, expected, 'to be message'); } ), + toHaveValidityMessage: new AsymmetricTypedExpectExtension( + assertValueNodeAnswer, + assertNullableString, + (actual, expected) => { + const { asString = null } = actual.node.validationState.violation?.message ?? {}; + const pass = asString === expected; + + return pass || new InspectableComparisonError(asString, expected, 'to be message'); + } + ), + + toHaveDefaultConstraintMessage: new StaticConditionExpectExtension( + assertValueNodeAnswer, + matchDefaultMessage('constraint') + ), + + toHaveDefaultRequiredMessage: new StaticConditionExpectExtension( + assertValueNodeAnswer, + matchDefaultMessage('required') + ), + /** * Asserts that the `actual` {@link ComparableAnswer} has a string value which * starts with the `expected` string. diff --git a/packages/scenario/src/assertion/extensions/form-state.ts b/packages/scenario/src/assertion/extensions/form-state.ts index e0042f1a..c59bfecd 100644 --- a/packages/scenario/src/assertion/extensions/form-state.ts +++ b/packages/scenario/src/assertion/extensions/form-state.ts @@ -1,11 +1,10 @@ import { assertInstanceType } from '@getodk/common/lib/runtime-types/instance-predicates.ts'; import type { DeriveStaticVitestExpectExtension } from '@getodk/common/test/assertions/helpers.ts'; import { + ArbitraryConditionExpectExtension, extendExpect, - StaticConditionExpectExtension, } from '@getodk/common/test/assertions/helpers.ts'; import { expect } from 'vitest'; -import { ConstraintImplementationPendingError } from '../../error/ConstraintImplementationPendingError.ts'; import { JRFormDef } from '../../jr/form/JRFormDef.ts'; type AssertJRFormDef = (value: unknown) => asserts value is JRFormDef; @@ -31,12 +30,12 @@ const formStateExtensions = extendExpect(expect, { * ]) * ``` */ - toBeValid: new StaticConditionExpectExtension(assertJRFormDef, { - currentState: { - get valid(): boolean { - throw new ConstraintImplementationPendingError(); - }, - }, + toBeValid: new ArbitraryConditionExpectExtension(assertJRFormDef, (actual) => { + if (actual.scenario.instanceRoot.validationState.violations.length) { + return new Error(); + } + + return true; }), }); diff --git a/packages/scenario/src/assertion/extensions/node-state.ts b/packages/scenario/src/assertion/extensions/node-state.ts index 82fc97f9..86f7a48e 100644 --- a/packages/scenario/src/assertion/extensions/node-state.ts +++ b/packages/scenario/src/assertion/extensions/node-state.ts @@ -71,6 +71,13 @@ const nodeStateExtensions = extendExpect(expect, { currentState: { relevant: false }, }), + toBeRequired: new StaticConditionExpectExtension(assertEngineNode, { + currentState: { required: true }, + }), + toBeOptional: new StaticConditionExpectExtension(assertEngineNode, { + currentState: { required: false }, + }), + /** * **PORTING NOTES** * diff --git a/packages/scenario/src/assertion/extensions/shared-type-assertions.ts b/packages/scenario/src/assertion/extensions/shared-type-assertions.ts index a0fa872e..bfdd8eea 100644 --- a/packages/scenario/src/assertion/extensions/shared-type-assertions.ts +++ b/packages/scenario/src/assertion/extensions/shared-type-assertions.ts @@ -1,5 +1,6 @@ import { assertUnknownObject } from '@getodk/common/lib/type-assertions/assertUnknownObject.ts'; import { arrayOfAssertion } from '@getodk/common/test/assertions/arrayOfAssertion.ts'; +import type { TypeofAssertion } from '@getodk/common/test/assertions/typeofAssertion.ts'; import { typeofAssertion } from '@getodk/common/test/assertions/typeofAssertion.ts'; import type { AnyNode, RootNode } from '@getodk/xforms-engine'; @@ -50,6 +51,14 @@ export const assertEngineNode: AssertEngineNode = (node) => { } }; -export const assertString = typeofAssertion('string'); +export const assertString: TypeofAssertion<'string'> = typeofAssertion('string'); + +type AssertNullableString = (value: unknown) => asserts value is string | null | undefined; + +export const assertNullableString: AssertNullableString = (value) => { + if (value != null) { + assertString(value); + } +}; export const assertArrayOfStrings = arrayOfAssertion(assertString, 'string'); diff --git a/packages/scenario/src/error/ConstraintImplementationPendingError.ts b/packages/scenario/src/error/ConstraintImplementationPendingError.ts deleted file mode 100644 index 0990a078..00000000 --- a/packages/scenario/src/error/ConstraintImplementationPendingError.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { ImplementationPendingError } from './ImplementationPendingError.ts'; - -export class ConstraintImplementationPendingError extends ImplementationPendingError { - constructor() { - super('constraint expressions'); - } -} diff --git a/packages/scenario/src/jr/Scenario.ts b/packages/scenario/src/jr/Scenario.ts index 61cccb65..45223b72 100644 --- a/packages/scenario/src/jr/Scenario.ts +++ b/packages/scenario/src/jr/Scenario.ts @@ -3,8 +3,8 @@ import type { AnyNode, RootNode, SelectNode } from '@getodk/xforms-engine'; import type { Accessor, Setter } from 'solid-js'; import { createMemo, createSignal, runWithOwner } from 'solid-js'; import { afterEach, expect } from 'vitest'; -import type { ComparableAnswer } from '../answer/ComparableAnswer.ts'; 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 { initializeTestForm } from '../client/init.ts'; @@ -30,8 +30,7 @@ import { JRFormIndex } from './form/JRFormIndex.ts'; import type { FormDefinitionResource } from './resource/FormDefinitionResource.ts'; import { r } from './resource/ResourcePathHelper.ts'; import { SelectChoiceList } from './select/SelectChoiceList.ts'; -import type { ValidateOutcome } from './validation/ValidateOutcome.ts'; -import { ValidationImplementationPendingError } from './validation/ValidationImplementationPendingError.ts'; +import { ValidateOutcome } from './validation/ValidateOutcome.ts'; import { JREvaluationContext } from './xpath/JREvaluationContext.ts'; import { JRTreeReference } from './xpath/JRTreeReference.ts'; @@ -288,6 +287,16 @@ export class Scenario { return this.setNonTerminalEventPosition(increment, expectReference); } + private getPositionalStateForReference(reference: string): AnyPositionalEvent | null { + const events = this.getPositionalEvents(); + + return ( + events.find(({ node }) => { + return node?.currentState.reference === reference; + }) ?? null + ); + } + private setPositionalStateToReference(reference: string): AnyPositionalEvent { const events = this.getPositionalEvents(); const index = events.findIndex(({ node }) => { @@ -303,7 +312,7 @@ export class Scenario { return this.setNonTerminalEventPosition(() => index, reference); } - private answerSelect(reference: string, ...selectionValues: string[]): ComparableAnswer { + private answerSelect(reference: string, ...selectionValues: string[]): ValueNodeAnswer { const event = this.setPositionalStateToReference(reference); if (!isQuestionEventOfType(event, 'select')) { @@ -315,7 +324,7 @@ export class Scenario { return event.answerQuestion(new SelectValuesAnswer(selectionValues)); } - answer(...args: AnswerParameters): unknown { + answer(...args: AnswerParameters): ValueNodeAnswer { if (isAnswerSelectParams(args)) { return this.answerSelect(...args); } @@ -356,7 +365,7 @@ export class Scenario { return event.answerQuestion(value); } - answerOf(reference: string): ComparableAnswer { + answerOf(reference: string): ValueNodeAnswer { return answerOf(this.instanceRoot, reference); } @@ -580,11 +589,23 @@ export class Scenario { return Promise.reject(new UnclearApplicabilityError('Scenario instance statefulness')); } - /** - * @todo - */ getValidationOutcome(): ValidateOutcome { - throw new ValidationImplementationPendingError(); + const [first, ...rest] = this.instanceRoot.validationState.violations; + + if (rest.length > 0) { + throw new Error( + 'TODO: what is an appropriate form-/Scenario-level ValidationOutcome when there is more than one violation?' + ); + } + + if (first == null) { + return new ValidateOutcome(null, null); + } + + const { reference, violation } = first; + const failedPrompt = this.getPositionalStateForReference(reference); + + return new ValidateOutcome(failedPrompt, violation); } /** diff --git a/packages/scenario/src/jr/event/SelectQuestionEvent.ts b/packages/scenario/src/jr/event/SelectQuestionEvent.ts index 9801eef1..ee24ab4c 100644 --- a/packages/scenario/src/jr/event/SelectQuestionEvent.ts +++ b/packages/scenario/src/jr/event/SelectQuestionEvent.ts @@ -1,8 +1,8 @@ import { xmlXPathWhitespaceSeparatedList } from '@getodk/common/lib/string/whitespace.ts'; import type { SelectNode } from '@getodk/xforms-engine'; -import type { ComparableAnswer } from '../../answer/ComparableAnswer.ts'; import { SelectNodeAnswer } from '../../answer/SelectNodeAnswer.ts'; import { UntypedAnswer } from '../../answer/UntypedAnswer.ts'; +import type { ValueNodeAnswer } from '../../answer/ValueNodeAnswer.ts'; import type { Scenario } from '../Scenario.ts'; import { SelectChoice } from '../select/SelectChoice.ts'; import { QuestionEvent } from './QuestionEvent.ts'; @@ -41,7 +41,7 @@ export class SelectQuestionEvent extends QuestionEvent<'select'> { * behavior! For now it's consistent with the internals (which we shouldn't * need to know about here because {@link Scenario} is a client) */ - answerQuestion(answerValue: unknown): ComparableAnswer { + answerQuestion(answerValue: unknown): ValueNodeAnswer { const { node } = this; const { stringValue } = new UntypedAnswer(answerValue); diff --git a/packages/scenario/src/jr/event/StringQuestionEvent.ts b/packages/scenario/src/jr/event/StringQuestionEvent.ts index f1e22b97..cb4c9d81 100644 --- a/packages/scenario/src/jr/event/StringQuestionEvent.ts +++ b/packages/scenario/src/jr/event/StringQuestionEvent.ts @@ -1,6 +1,6 @@ -import type { ComparableAnswer } from '../../answer/ComparableAnswer.ts'; import { StringNodeAnswer } from '../../answer/StringNodeAnswer.ts'; import { UntypedAnswer } from '../../answer/UntypedAnswer.ts'; +import type { ValueNodeAnswer } from '../../answer/ValueNodeAnswer.ts'; import { QuestionEvent } from './QuestionEvent.ts'; export class StringInputQuestionEvent extends QuestionEvent<'string'> { @@ -8,7 +8,7 @@ export class StringInputQuestionEvent extends QuestionEvent<'string'> { return new StringNodeAnswer(this.node); } - answerQuestion(answerValue: unknown): ComparableAnswer { + answerQuestion(answerValue: unknown): ValueNodeAnswer { const { stringValue } = new UntypedAnswer(answerValue); this.node.setValue(stringValue); diff --git a/packages/scenario/src/jr/validation/ValidateOutcome.ts b/packages/scenario/src/jr/validation/ValidateOutcome.ts index 0f55d2b8..765c8c28 100644 --- a/packages/scenario/src/jr/validation/ValidateOutcome.ts +++ b/packages/scenario/src/jr/validation/ValidateOutcome.ts @@ -1,5 +1,5 @@ +import type { AnyViolation } from '@getodk/xforms-engine'; import type { AnyPositionalEvent } from '../event/getPositionalEvents.ts'; -import { ValidationImplementationPendingError } from './ValidationImplementationPendingError.ts'; export const ANSWER_OK = 'ANSWER_OK'; export const ANSWER_REQUIRED_BUT_EMPTY = 'ANSWER_REQUIRED_BUT_EMPTY'; @@ -19,11 +19,23 @@ type ValidationOutcomeStatus = ValidationOutcomeStatuses[keyof ValidationOutcome * @todo */ export class ValidateOutcome { - get failedPrompt(): AnyPositionalEvent | null { - throw new ValidationImplementationPendingError(); - } + readonly outcome: ValidationOutcomeStatus; + + constructor( + readonly failedPrompt: AnyPositionalEvent | null, + violation: AnyViolation | null + ) { + switch (violation?.condition) { + case 'constraint': + this.outcome = 'ANSWER_CONSTRAINT_VIOLATED'; + break; + + case 'required': + this.outcome = 'ANSWER_REQUIRED_BUT_EMPTY'; + break; - get outcome(): ValidationOutcomeStatus { - throw new ValidationImplementationPendingError(); + default: + this.outcome = 'ANSWER_OK'; + } } } diff --git a/packages/scenario/src/jr/validation/ValidationImplementationPendingError.ts b/packages/scenario/src/jr/validation/ValidationImplementationPendingError.ts deleted file mode 100644 index 903a7baa..00000000 --- a/packages/scenario/src/jr/validation/ValidationImplementationPendingError.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { ImplementationPendingError } from '../../error/ImplementationPendingError.ts'; - -export class ValidationImplementationPendingError extends ImplementationPendingError { - constructor() { - super('Validation (constraint, required, etc)'); - } -} diff --git a/packages/scenario/test/select.test.ts b/packages/scenario/test/select.test.ts index 0adf62f1..a6dca6f5 100644 --- a/packages/scenario/test/select.test.ts +++ b/packages/scenario/test/select.test.ts @@ -1161,9 +1161,18 @@ describe('SelectOneChoiceFilterTest.java', () => { * This test is clearly a valuable exercise of `required` validation logic, * but it seems tangential to the description. * - * A supplemental test is added below, exercising the checks for cleared values independent of the validation checks. + * A supplemental test is added below, exercising the checks for cleared + * values independent of the validation checks. * - * This test currently fails pending implementation of validation. + * This test currently fails pending full support for updating select nodes' + * selection values when their itemset options are filtered. + * + * The validation assertions have been (temporarily?) refined to check only + * the **reference** of expected invalid nodes, rather than comparing to the + * full node object. This is necessary to prevent slow and expensive logging + * of the full node structure (really the full instance tree, as the nodes + * reference `root`) on assertion failure. The original assertions are left + * commented out, so they can be restored when the test passes. */ it.fails('should clear [and validate] values at levels 2 and 3', async () => { const scenario = await Scenario.init('three-level-cascading-select.xml'); @@ -1179,7 +1188,8 @@ describe('SelectOneChoiceFilterTest.java', () => { let validate = scenario.getValidationOutcome(); - expect(validate.failedPrompt).toBe(scenario.indexOf('/data/level2')); + // expect(validate.failedPrompt).toBe(scenario.indexOf('/data/level2')); + expect(validate.failedPrompt?.node?.currentState.reference).toBe('/data/level2'); expect(validate.outcome).toBe(ANSWER_REQUIRED_BUT_EMPTY); // If we set level2 to "aa", form validation passes. Currently, clearing a choice only updates filter expressions @@ -1190,7 +1200,8 @@ describe('SelectOneChoiceFilterTest.java', () => { validate = scenario.getValidationOutcome(); - expect(validate.failedPrompt).toBe(scenario.indexOf('/data/level3')); + // expect(validate.failedPrompt).toBe(scenario.indexOf('/data/level3')); + expect(validate.failedPrompt?.node?.currentState.reference).toBe('/data/level3'); expect(validate.outcome).toBe(ANSWER_REQUIRED_BUT_EMPTY); }); diff --git a/packages/scenario/test/validity-state.test.ts b/packages/scenario/test/validity-state.test.ts index 76a27955..aa847586 100644 --- a/packages/scenario/test/validity-state.test.ts +++ b/packages/scenario/test/validity-state.test.ts @@ -16,6 +16,7 @@ import { AnswerResult, Scenario } from '../src/jr/Scenario.ts'; import { r } from '../src/jr/resource/ResourcePathHelper.ts'; import { ANSWER_CONSTRAINT_VIOLATED, + ANSWER_OK, ANSWER_REQUIRED_BUT_EMPTY, } from '../src/jr/validation/ValidateOutcome.ts'; @@ -36,7 +37,7 @@ import { describe('TriggerableDagTest.java', () => { describe('//region Required and constraint', () => { describe('constraints of fields that are empty', () => { - it.fails('[is] are always satisfied', async () => { + it('[is] are always satisfied', async () => { const scenario = await Scenario.init( 'Some form', html( @@ -62,7 +63,7 @@ describe('TriggerableDagTest.java', () => { }); describe('empty required fields', () => { - it.fails('make[s] form validation fail', async () => { + it('make[s] form validation fail', async () => { const scenario = await Scenario.init( 'Some form', html( @@ -86,50 +87,78 @@ describe('TriggerableDagTest.java', () => { }); describe('constraint violations and form finalization', () => { - it.fails('[has no clear BDD-ish description equivalent]', async () => { - const scenario = await Scenario.init( - 'Some form', - html( - head( - title('Some form'), - model( - mainInstance(t('data id="some-form"', t('a'), t('b'))), - bind('/data/a').type('string').constraint('/data/b'), - bind('/data/b').type('boolean') + interface CastReadonlyExpressionOptions { + readonly castReadonlyExpressionsAsNumber: boolean; + } + + describe.each([ + { castReadonlyExpressionsAsNumber: false }, + { castReadonlyExpressionsAsNumber: true }, + ])( + 'readonly (cast readonly expression as number: $castReadonlyExpressionAsNumber)', + ({ castReadonlyExpressionsAsNumber }) => { + let testFn: typeof it | typeof it.fails; + + if (castReadonlyExpressionsAsNumber) { + testFn = it; + } else { + testFn = it.fails; + } + + let castReadonlyExpression: (baseExpression: string) => string; + + if (castReadonlyExpressionsAsNumber) { + castReadonlyExpression = (baseExpression) => `number(${baseExpression})`; + } else { + castReadonlyExpression = (baseExpression) => baseExpression; + } + + testFn('[has no clear BDD-ish description equivalent]', async () => { + const scenario = await Scenario.init( + 'Some form', + html( + head( + title('Some form'), + model( + mainInstance(t('data id="some-form"', t('a'), t('b'))), + bind('/data/a').type('string').constraint(castReadonlyExpression('/data/b')), + bind('/data/b').type('boolean') + ) + ), + body(input('/data/a'), input('/data/b')) ) - ), - body(input('/data/a'), input('/data/b')) - ) - ); + ); - // First, ensure we will be able to commit an answer in /data/a by - // making it match its constraint. No values can be committed to the - // instance if constraints aren't satisfied. - scenario.answer('/data/b', true); + // First, ensure we will be able to commit an answer in /data/a by + // making it match its constraint. No values can be committed to the + // instance if constraints aren't satisfied. + scenario.answer('/data/b', true); - // Then, commit an answer (answers with empty values are always valid) - scenario.answer('/data/a', 'cocotero'); + // Then, commit an answer (answers with empty values are always valid) + scenario.answer('/data/a', 'cocotero'); - // Then, make the constraint defined at /data/a impossible to satisfy - scenario.answer('/data/b', false); + // Then, make the constraint defined at /data/a impossible to satisfy + scenario.answer('/data/b', false); - // At this point, the form has /data/a filled with an answer that's - // invalid according to its constraint expression, but we can't be - // aware of that, unless we validate the whole form. - // - // Clients like Collect will validate the whole form before marking - // a submission as complete and saving it to the filesystem. - // - // FormDef.validate(boolean) will go through all the relevant fields - // re-answering them with their current values in order to detect - // any constraint violations. When this happens, a non-null - // ValidationOutcome object is returned including information about - // the violated constraint. - const validate = scenario.getValidationOutcome(); + // At this point, the form has /data/a filled with an answer that's + // invalid according to its constraint expression, but we can't be + // aware of that, unless we validate the whole form. + // + // Clients like Collect will validate the whole form before marking + // a submission as complete and saving it to the filesystem. + // + // FormDef.validate(boolean) will go through all the relevant fields + // re-answering them with their current values in order to detect + // any constraint violations. When this happens, a non-null + // ValidationOutcome object is returned including information about + // the violated constraint. + const validate = scenario.getValidationOutcome(); - expect(validate.failedPrompt).toBe(scenario.indexOf('/data/a')); - expect(validate.outcome).toBe(ANSWER_CONSTRAINT_VIOLATED); - }); + expect(validate.failedPrompt).toBe(scenario.indexOf('/data/a')); + expect(validate.outcome).toBe(ANSWER_CONSTRAINT_VIOLATED); + }); + } + ); }); }); }); @@ -144,29 +173,50 @@ describe('TriggerableDagTest.java', () => { */ describe('`constraint`', () => { describe('FormDefTest.java', () => { + interface PrimaryInstanceIdOptions { + readonly temporarilyIncludePrimaryInstanceId: boolean; + } + /** * **PORTING NOTES** * - * - From Slack discussion, there's probably no meaning to this fixture - * having a `.xhtml` suffix. Rename to `.xml` for consistency? (Would be - * helpful for find-in-project filtering). - * - * - Currently fails on form init, as the primary instance does not have an - * `id` attribute. Deferring accommodation of that as the test is also - * expected to fail pending `constraint` feature support. + * Fails on form init, as the primary instance does not have an `id` + * attribute. Parameterized to demonstrate test is now passing otherwise. */ - it.fails('enforces `constraint`s defined [on] in a field', async () => { - const scenario = await Scenario.init(r('ImageSelectTester.xhtml')); + describe.each([ + { temporarilyIncludePrimaryInstanceId: false }, + { temporarilyIncludePrimaryInstanceId: true }, + ])( + 'temporarily include primary instance id: $temporarilyIncludePrimaryInstanceId', + ({ temporarilyIncludePrimaryInstanceId }) => { + let testFn: typeof it | typeof it.fails; - scenario.next('/icons/id'); - scenario.next('/icons/name'); - scenario.next('/icons/find-mirc'); - scenario.next('/icons/non-local'); - scenario.next('/icons/consTest'); + if (temporarilyIncludePrimaryInstanceId) { + testFn = it; + } else { + testFn = it.fails; + } - expect(scenario.answer('10')).toHaveValidityStatus(AnswerResult.CONSTRAINT_VIOLATED); - expect(scenario.answer('13')).toHaveValidityStatus(AnswerResult.OK); - }); + testFn('enforces `constraint`s defined [on] in a field', async () => { + const scenario = await Scenario.init( + r( + temporarilyIncludePrimaryInstanceId + ? 'ImageSelectTester-alt.xml' + : 'ImageSelectTester.xml' + ) + ); + + scenario.next('/icons/id'); + scenario.next('/icons/name'); + scenario.next('/icons/find-mirc'); + scenario.next('/icons/non-local'); + scenario.next('/icons/consTest'); + + expect(scenario.answer('10')).toHaveValidityStatus(AnswerResult.CONSTRAINT_VIOLATED); + expect(scenario.answer('13')).toHaveValidityStatus(AnswerResult.OK); + }); + } + ); }); /** @@ -193,6 +243,11 @@ describe('`constraint`', () => { * mechanism for the assertion as-ported. (It still has an `unknown` return * type, which we can make more specific if we agree to introduce such a * substantial difference in the {@link Scenario} API.) + * + * - Test exercises (de)serialization (which we do not support), as well as + * validation. The former is the nature of failure as ported. An alternate + * test has been added below demonstrating that validation otherwise works + * as expected. */ it.fails('enforces `constraint`s when [an] instance is deserialized', async () => { const formDef = html( @@ -230,4 +285,251 @@ describe('`constraint`', () => { expect(result).toHaveValidityStatus(AnswerResult.CONSTRAINT_VIOLATED); }); + + it('enforces an arbitrary regex `constraint` expression (alternate to test above)', async () => { + const formDef = html( + head( + title('Some form'), + model( + mainInstance(t('data id="some-form"', t('a'))), + bind('/data/a').type('string').constraint("regex(.,'[0-9]{10}')") + ) + ), + body(input('/data/a')) + ); + + const scenario = await Scenario.init('Some form', formDef); + + scenario.next('/data/a'); + + let result = scenario.answer('00000'); + + expect(result).toHaveValidityStatus(AnswerResult.CONSTRAINT_VIOLATED); + + result = scenario.answer('0000000000'); + + expect(result).toHaveValidityStatus(AnswerResult.OK); + + result = scenario.answer('00000'); + + expect(result).toHaveValidityStatus(AnswerResult.CONSTRAINT_VIOLATED); + }); +}); + +describe('Validity messages', () => { + interface ValidationMessageOptions { + readonly constraintMsg?: string; + readonly requiredMsg?: string; + } + + const initValidationFixture = async ( + options: ValidationMessageOptions = {} + ): Promise => { + const { constraintMsg, requiredMsg } = options; + + let bindConstrainedInput = bind('/data/constrained-input').constraint("regex(.,'[0-9]{10}')"); + + if (constraintMsg != null) { + bindConstrainedInput = bindConstrainedInput.withAttribute( + 'jr', + 'constraintMsg', + constraintMsg + ); + } + + let bindRequiredInput = bind('/data/required-input').required(); + + if (requiredMsg != null) { + bindRequiredInput = bindRequiredInput.withAttribute('jr', 'requiredMsg', requiredMsg); + } + + return Scenario.init( + 'Validation fixture', + html( + head( + title('Validation fixture'), + model( + mainInstance( + t('data id="validation-fixture"', t('constrained-input'), t('required-input')) + ), + bindConstrainedInput, + bindRequiredInput + ) + ), + body(input('/data/constrained-input'), input('/data/required-input')) + ) + ); + }; + + it('provides a form-defined message on constraint validation failure', async () => { + const constraintMsg = 'Must be ten digits'; + const scenario = await initValidationFixture({ constraintMsg }); + + let result = scenario.answer('/data/constrained-input', '00000'); + + expect(result).toHaveConstraintMessage(constraintMsg); + expect(result).toHaveRequiredMessage(null); + expect(result).toHaveValidityMessage(constraintMsg); + + result = scenario.answer('/data/constrained-input', '0000000000'); + + expect(result).toHaveConstraintMessage(null); + expect(result).toHaveRequiredMessage(null); + expect(result).toHaveValidityMessage(null); + + result = scenario.answer('/data/constrained-input', '00000'); + + expect(result).toHaveConstraintMessage(constraintMsg); + expect(result).toHaveRequiredMessage(null); + expect(result).toHaveValidityMessage(constraintMsg); + }); + + it('provides an engine-defined message on constraint validation failure', async () => { + const scenario = await initValidationFixture(); + + let result = scenario.answer('/data/constrained-input', '00000'); + + expect(result).toHaveDefaultConstraintMessage(); + expect(result).toHaveRequiredMessage(null); + + result = scenario.answer('/data/constrained-input', '0000000000'); + + expect(result).toHaveConstraintMessage(null); + expect(result).toHaveRequiredMessage(null); + expect(result).toHaveValidityMessage(null); + + result = scenario.answer('/data/constrained-input', '00000'); + + expect(result).toHaveDefaultConstraintMessage(); + expect(result).toHaveRequiredMessage(null); + }); + + it('provides a form-defined message on required validation failure', async () => { + const requiredMsg = 'Must provide an answer!!'; + const scenario = await initValidationFixture({ requiredMsg }); + + let result = scenario.answerOf('/data/required-input'); + + expect(result).toHaveConstraintMessage(null); + expect(result).toHaveRequiredMessage(requiredMsg); + expect(result).toHaveValidityMessage(requiredMsg); + + result = scenario.answer('/data/required-input', '0000000000'); + + expect(result).toHaveConstraintMessage(null); + expect(result).toHaveRequiredMessage(null); + expect(result).toHaveValidityMessage(null); + + result = scenario.answer('/data/required-input', ''); + + expect(result).toHaveConstraintMessage(null); + expect(result).toHaveRequiredMessage(requiredMsg); + expect(result).toHaveValidityMessage(requiredMsg); + }); + + it('provides an engine-defined message on required validation failure', async () => { + const scenario = await initValidationFixture(); + + let result = scenario.answerOf('/data/required-input'); + + expect(result).toHaveDefaultRequiredMessage(); + expect(result).toHaveConstraintMessage(null); + + result = scenario.answer('/data/required-input', '0000000000'); + + expect(result).toHaveConstraintMessage(null); + expect(result).toHaveRequiredMessage(null); + expect(result).toHaveValidityMessage(null); + + result = scenario.answer('/data/required-input', ''); + + expect(result).toHaveDefaultRequiredMessage(); + expect(result).toHaveConstraintMessage(null); + }); +}); + +describe('Validation and relevance', () => { + it('does not produce validation errors for non-relevant questions', async () => { + const scenario = await Scenario.init( + 'Validation and relevance', + html( + head( + title('Validation and relevance'), + // prettier-ignore + model( + mainInstance( + t('data id="validation-and-relevance"', + t('a', 'default value'), + t('b', 'A'), + t('validated-when-relevant') + ) + ), + bind('/data/a'), + bind('/data/b'), + bind('/data/validated-when-relevant') + .relevant("/data/a != 'default value'") + .required('/data/b > 10') + ) + ), + body(input('/data/a'), input('/data/b'), input('/data/validated-when-relevant')) + ) + ); + + // Sanity check: initially non-relevant and optional + expect(scenario.getInstanceNode('/data/validated-when-relevant')).toBeNonRelevant(); + expect(scenario.getInstanceNode('/data/validated-when-relevant')).toBeOptional(); + + // Sanity check: no validation errors while optional + expect(scenario.answerOf('/data/validated-when-relevant')).toHaveValidityStatus( + AnswerResult.OK + ); + + // Make required condition effective + scenario.answer('/data/b', 11); + + // Sanity check: still non-relevant, now required + expect(scenario.getInstanceNode('/data/validated-when-relevant')).toBeNonRelevant(); + expect(scenario.getInstanceNode('/data/validated-when-relevant')).toBeRequired(); + + // Check form has no validation error while non-relevant + let validate = scenario.getValidationOutcome(); + + expect(validate.failedPrompt).toBeNull(); + expect(validate.outcome).toBe(ANSWER_OK); + + // Check answer/node has no validation error while non-relevant + expect(scenario.answerOf('/data/validated-when-relevant')).toHaveValidityStatus( + AnswerResult.OK + ); + + // Make relevant, sanity check + scenario.answer('/data/a', 'another value, not the default!'); + expect(scenario.getInstanceNode('/data/validated-when-relevant')).toBeRelevant(); + + // Check validation error while relevant + validate = scenario.getValidationOutcome(); + + expect(validate.failedPrompt).toBe(scenario.indexOf('/data/validated-when-relevant')); + expect(validate.outcome).toBe(ANSWER_REQUIRED_BUT_EMPTY); + + // Check answer/node has no validation error while non-relevant + expect(scenario.answerOf('/data/validated-when-relevant')).toHaveValidityStatus( + AnswerResult.REQUIRED_BUT_EMPTY + ); + + // Make non-relevant again + scenario.answer('/data/a', 'default value'); + expect(scenario.getInstanceNode('/data/validated-when-relevant')).toBeNonRelevant(); + + // Check form has no validation error again + validate = scenario.getValidationOutcome(); + + expect(validate.failedPrompt).toBeNull(); + expect(validate.outcome).toBe(ANSWER_OK); + + // Check answer/node has no validation error again + expect(scenario.answerOf('/data/validated-when-relevant')).toHaveValidityStatus( + AnswerResult.OK + ); + }); }); diff --git a/packages/web-forms/src/components/RepeatInstance.vue b/packages/web-forms/src/components/RepeatInstance.vue index cc8984db..17cd83a4 100644 --- a/packages/web-forms/src/components/RepeatInstance.vue +++ b/packages/web-forms/src/components/RepeatInstance.vue @@ -28,8 +28,8 @@ const label = computed(() => { // Use RepeatRangeNode label if it's there // TODO/sk: use state.label.asString - if(props.instance.parent.definition.bodyElement.label?.children[0]?.stringValue){ - return `${props.instance.parent.definition.bodyElement.label?.children[0].stringValue}`; + if(props.instance.parent.definition.bodyElement.label?.chunks[0]?.stringValue){ + return `${props.instance.parent.definition.bodyElement.label?.chunks[0].stringValue}`; } return `Repeat Item`; diff --git a/packages/xforms-engine/src/body/BodyElementDefinition.ts b/packages/xforms-engine/src/body/BodyElementDefinition.ts index 4c68b1ee..b08aa022 100644 --- a/packages/xforms-engine/src/body/BodyElementDefinition.ts +++ b/packages/xforms-engine/src/body/BodyElementDefinition.ts @@ -1,8 +1,9 @@ import type { XFormDefinition } from '../XFormDefinition.ts'; import { DependencyContext } from '../expression/DependencyContext.ts'; +import type { HintDefinition } from '../parse/text/HintDefinition.ts'; +import type { ItemLabelDefinition } from '../parse/text/ItemLabelDefinition.ts'; +import type { LabelDefinition } from '../parse/text/LabelDefinition.ts'; import type { BodyElementParentContext } from './BodyDefinition.ts'; -import type { HintDefinition } from './text/HintDefinition.ts'; -import type { LabelDefinition } from './text/LabelDefinition.ts'; /** * These category names roughly correspond to each of the ODK XForms spec's @@ -22,7 +23,7 @@ export abstract class BodyElementDefinition extends Depende abstract readonly category: BodyElementCategory; abstract readonly type: Type; readonly hint: HintDefinition | null = null; - readonly label: LabelDefinition | null = null; + readonly label: ItemLabelDefinition | LabelDefinition | null = null; readonly reference: string | null = null; readonly parentReference: string | null; diff --git a/packages/xforms-engine/src/body/RepeatElementDefinition.ts b/packages/xforms-engine/src/body/RepeatElementDefinition.ts index 67c0e986..6e0d4672 100644 --- a/packages/xforms-engine/src/body/RepeatElementDefinition.ts +++ b/packages/xforms-engine/src/body/RepeatElementDefinition.ts @@ -1,11 +1,11 @@ import { JAVAROSA_NAMESPACE_URI } from '@getodk/common/constants/xmlns.ts'; import type { XFormDefinition } from '../XFormDefinition.ts'; +import { LabelDefinition } from '../parse/text/LabelDefinition.ts'; import type { BodyElementDefinitionArray, BodyElementParentContext } from './BodyDefinition.ts'; import { BodyDefinition } from './BodyDefinition.ts'; import { BodyElementDefinition } from './BodyElementDefinition.ts'; import type { StructureElementAppearanceDefinition } from './appearance/structureElementAppearanceParser.ts'; import { structureElementAppearanceParser } from './appearance/structureElementAppearanceParser.ts'; -import { LabelDefinition } from './text/LabelDefinition.ts'; export class RepeatElementDefinition extends BodyElementDefinition<'repeat'> { static override isCompatible(localName: string): boolean { diff --git a/packages/xforms-engine/src/body/control/ControlDefinition.ts b/packages/xforms-engine/src/body/control/ControlDefinition.ts index bbe167bf..15e3c22d 100644 --- a/packages/xforms-engine/src/body/control/ControlDefinition.ts +++ b/packages/xforms-engine/src/body/control/ControlDefinition.ts @@ -1,9 +1,9 @@ import type { XFormDefinition } from '../../XFormDefinition.ts'; import type { ParsedTokenList } from '../../lib/TokenListParser.ts'; +import { HintDefinition } from '../../parse/text/HintDefinition.ts'; +import { LabelDefinition } from '../../parse/text/LabelDefinition.ts'; import type { BodyElementParentContext } from '../BodyDefinition.ts'; import { BodyElementDefinition } from '../BodyElementDefinition.ts'; -import { HintDefinition } from '../text/HintDefinition.ts'; -import { LabelDefinition } from '../text/LabelDefinition.ts'; // prettier-ignore type ControlType = diff --git a/packages/xforms-engine/src/body/control/select/ItemDefinition.ts b/packages/xforms-engine/src/body/control/select/ItemDefinition.ts index 63e44cc5..acd29721 100644 --- a/packages/xforms-engine/src/body/control/select/ItemDefinition.ts +++ b/packages/xforms-engine/src/body/control/select/ItemDefinition.ts @@ -1,14 +1,14 @@ import { getValueElement, type ItemElement } from '../../../lib/dom/query.ts'; +import { ItemLabelDefinition } from '../../../parse/text/ItemLabelDefinition.ts'; import type { XFormDefinition } from '../../../XFormDefinition.ts'; import { BodyElementDefinition } from '../../BodyElementDefinition.ts'; -import { LabelDefinition } from '../../text/LabelDefinition.ts'; import type { AnySelectDefinition } from './SelectDefinition.ts'; export class ItemDefinition extends BodyElementDefinition<'item'> { override readonly category = 'support'; override readonly type = 'item'; - override readonly label: LabelDefinition | null; + override readonly label: ItemLabelDefinition | null; readonly value: string; constructor( @@ -25,7 +25,7 @@ export class ItemDefinition extends BodyElementDefinition<'item'> { super(form, parent, element); - this.label = LabelDefinition.forItem(form, this); + this.label = ItemLabelDefinition.from(form, this); this.value = value; } } diff --git a/packages/xforms-engine/src/body/control/select/ItemsetDefinition.ts b/packages/xforms-engine/src/body/control/select/ItemsetDefinition.ts index 7b6a935e..1e288c70 100644 --- a/packages/xforms-engine/src/body/control/select/ItemsetDefinition.ts +++ b/packages/xforms-engine/src/body/control/select/ItemsetDefinition.ts @@ -1,7 +1,7 @@ import { getValueElement, type ItemsetElement } from '../../../lib/dom/query.ts'; +import { ItemLabelDefinition } from '../../../parse/text/ItemLabelDefinition.ts'; import type { XFormDefinition } from '../../../XFormDefinition.ts'; import { BodyElementDefinition } from '../../BodyElementDefinition.ts'; -import { LabelDefinition } from '../../text/LabelDefinition.ts'; import { ItemsetNodesetExpression } from './ItemsetNodesetExpression.ts'; import { ItemsetValueExpression } from './ItemsetValueExpression.ts'; import type { AnySelectDefinition } from './SelectDefinition.ts'; @@ -11,7 +11,7 @@ export class ItemsetDefinition extends BodyElementDefinition<'itemset'> { readonly type = 'itemset'; override readonly reference: string; - override readonly label: LabelDefinition | null; + override readonly label: ItemLabelDefinition | null; readonly nodes: ItemsetNodesetExpression; readonly value: ItemsetValueExpression; @@ -31,6 +31,6 @@ export class ItemsetDefinition extends BodyElementDefinition<'itemset'> { this.reference = nodesetExpression; this.nodes = new ItemsetNodesetExpression(this, nodesetExpression); this.value = new ItemsetValueExpression(this, valueExpression); - this.label = LabelDefinition.forItemset(form, this); + this.label = ItemLabelDefinition.from(form, this); } } diff --git a/packages/xforms-engine/src/body/group/BaseGroupDefinition.ts b/packages/xforms-engine/src/body/group/BaseGroupDefinition.ts index 14ec616b..98e8fcc7 100644 --- a/packages/xforms-engine/src/body/group/BaseGroupDefinition.ts +++ b/packages/xforms-engine/src/body/group/BaseGroupDefinition.ts @@ -1,6 +1,7 @@ import { UpsertableMap } from '@getodk/common/lib/collections/UpsertableMap.ts'; import type { XFormDefinition } from '../../XFormDefinition.ts'; import { getLabelElement } from '../../lib/dom/query.ts'; +import { LabelDefinition } from '../../parse/text/LabelDefinition.ts'; import { BodyDefinition, type BodyElementDefinitionArray, @@ -9,7 +10,6 @@ import { import { BodyElementDefinition } from '../BodyElementDefinition.ts'; import type { StructureElementAppearanceDefinition } from '../appearance/structureElementAppearanceParser.ts'; import { structureElementAppearanceParser } from '../appearance/structureElementAppearanceParser.ts'; -import { LabelDefinition } from '../text/LabelDefinition.ts'; /** * These type names are derived from **and expand upon** the language used in diff --git a/packages/xforms-engine/src/body/group/PresentationGroupDefinition.ts b/packages/xforms-engine/src/body/group/PresentationGroupDefinition.ts index 1b9cb7ac..1ea9078b 100644 --- a/packages/xforms-engine/src/body/group/PresentationGroupDefinition.ts +++ b/packages/xforms-engine/src/body/group/PresentationGroupDefinition.ts @@ -1,6 +1,6 @@ import type { XFormDefinition } from '../../XFormDefinition.ts'; +import { LabelDefinition } from '../../parse/text/LabelDefinition.ts'; import type { BodyElementParentContext } from '../BodyDefinition.ts'; -import { LabelDefinition } from '../text/LabelDefinition.ts'; import { BaseGroupDefinition } from './BaseGroupDefinition.ts'; export class PresentationGroupDefinition extends BaseGroupDefinition<'presentation-group'> { diff --git a/packages/xforms-engine/src/body/text/HintDefinition.ts b/packages/xforms-engine/src/body/text/HintDefinition.ts deleted file mode 100644 index 69d8d019..00000000 --- a/packages/xforms-engine/src/body/text/HintDefinition.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { getHintElement } from '../../lib/dom/query.ts'; -import type { XFormDefinition } from '../../XFormDefinition.ts'; -import type { AnyControlDefinition } from '../control/ControlDefinition.ts'; -import type { TextElement } from './TextElementDefinition.ts'; -import { TextElementDefinition } from './TextElementDefinition.ts'; - -export interface HintElement extends TextElement { - readonly localName: 'hint'; -} - -export class HintDefinition extends TextElementDefinition<'hint'> { - static forElement( - form: XFormDefinition, - definition: AnyControlDefinition - ): HintDefinition | null { - const hintElement = getHintElement(definition.element); - - if (hintElement == null) { - return null; - } - - return new this(form, definition, hintElement); - } - - readonly type = 'hint'; -} diff --git a/packages/xforms-engine/src/body/text/LabelDefinition.ts b/packages/xforms-engine/src/body/text/LabelDefinition.ts deleted file mode 100644 index bcb1d20b..00000000 --- a/packages/xforms-engine/src/body/text/LabelDefinition.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { getLabelElement, getRepeatGroupLabelElement } from '../../lib/dom/query.ts'; -import type { XFormDefinition } from '../../XFormDefinition.ts'; -import type { AnyControlDefinition } from '../control/ControlDefinition.ts'; -import type { ItemDefinition } from '../control/select/ItemDefinition.ts'; -import type { ItemsetDefinition } from '../control/select/ItemsetDefinition.ts'; -import type { BaseGroupDefinition } from '../group/BaseGroupDefinition.ts'; -import type { RepeatElementDefinition } from '../RepeatElementDefinition.ts'; -import type { TextElement, TextElementOwner } from './TextElementDefinition.ts'; -import { TextElementDefinition } from './TextElementDefinition.ts'; - -export interface LabelElement extends TextElement { - readonly localName: 'label'; -} - -type StaticLabelContext = Exclude; - -export class LabelDefinition extends TextElementDefinition<'label'> { - protected static staticDefinition( - form: XFormDefinition, - definition: StaticLabelContext - ): LabelDefinition | null { - const labelElement = getLabelElement(definition.element); - - if (labelElement == null) { - return null; - } - - return new this(form, definition, labelElement); - } - - static forControl(form: XFormDefinition, control: AnyControlDefinition): LabelDefinition | null { - return this.staticDefinition(form, control); - } - - static forRepeatGroup( - form: XFormDefinition, - repeat: RepeatElementDefinition - ): LabelDefinition | null { - const repeatGroupLabel = getRepeatGroupLabelElement(repeat.element); - - if (repeatGroupLabel == null) { - return null; - } - - return new this(form, repeat, repeatGroupLabel); - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - static forGroup(form: XFormDefinition, group: BaseGroupDefinition): LabelDefinition | null { - return this.staticDefinition(form, group); - } - - static forItem(form: XFormDefinition, item: ItemDefinition): LabelDefinition | null { - return this.staticDefinition(form, item); - } - - static forItemset(form: XFormDefinition, itemset: ItemsetDefinition): LabelDefinition | null { - const labelElement = getLabelElement(itemset.element); - - if (labelElement == null) { - return null; - } - - return new this(form, itemset, labelElement); - } - - readonly type = 'label'; -} diff --git a/packages/xforms-engine/src/body/text/TextElementDefinition.ts b/packages/xforms-engine/src/body/text/TextElementDefinition.ts deleted file mode 100644 index d5b5c8d2..00000000 --- a/packages/xforms-engine/src/body/text/TextElementDefinition.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { isCommentNode, isElementNode, isTextNode } from '@getodk/common/lib/dom/predicates.ts'; -import type { XFormDefinition } from '../../XFormDefinition.ts'; -import { type AnyDependentExpression } from '../../expression/DependentExpression.ts'; -import type { AnyGroupElementDefinition } from '../BodyDefinition.ts'; -import { BodyElementDefinition } from '../BodyElementDefinition.ts'; -import type { RepeatElementDefinition } from '../RepeatElementDefinition.ts'; -import type { AnyControlDefinition } from '../control/ControlDefinition.ts'; -import type { ItemDefinition } from '../control/select/ItemDefinition.ts'; -import type { ItemsetDefinition } from '../control/select/ItemsetDefinition.ts'; -import { TextElementOutputPart } from './TextElementOutputPart.ts'; -import { TextElementReferencePart } from './TextElementReferencePart.ts'; -import { TextElementStaticPart } from './TextElementStaticPart.ts'; - -export type TextElementType = 'hint' | 'label'; - -export interface TextElement extends Element { - readonly localName: TextElementType; -} - -export type TextElementOwner = - | AnyControlDefinition - | AnyGroupElementDefinition - | ItemDefinition - | ItemsetDefinition - | RepeatElementDefinition; - -export type TextElementChild = TextElementOutputPart | TextElementStaticPart; - -export abstract class TextElementDefinition< - Type extends TextElementType, -> extends BodyElementDefinition { - readonly category = 'support'; - abstract override readonly type: Type; - - override readonly reference: string | null; - override readonly parentReference: string | null; - - readonly referenceExpression: TextElementReferencePart | null; - readonly children: readonly TextElementChild[]; - - override get isTranslated(): boolean { - return this.owner.isTranslated; - } - - override set isTranslated(value: true) { - this.owner.isTranslated = value; - } - - protected constructor( - form: XFormDefinition, - readonly owner: TextElementOwner, - element: TextElement - ) { - super(form, owner, element); - - this.reference = owner.reference; - this.parentReference = owner.parentReference; - this.referenceExpression = TextElementReferencePart.from(this, element); - - const children = Array.from(element.childNodes).flatMap((node) => { - if (isTextNode(node)) { - return new TextElementStaticPart(this, node); - } - - if (isElementNode(node)) { - const output = TextElementOutputPart.from(this, node); - - if (output != null) { - return output; - } - } - - if (isCommentNode(node)) { - return []; - } - - // eslint-disable-next-line no-console - console.error('Unexpected text element child', node); - - throw new Error(`Unexpected <${element.nodeName}> child element`); - }); - - this.children = children; - } - - override registerDependentExpression(expression: AnyDependentExpression): void { - this.owner.registerDependentExpression(expression); - } - - override toJSON(): object { - const { form, owner, parent, ...rest } = this; - - return rest; - } -} - -export type AnyTextElementDefinition = TextElementDefinition; diff --git a/packages/xforms-engine/src/body/text/TextElementOutputPart.ts b/packages/xforms-engine/src/body/text/TextElementOutputPart.ts deleted file mode 100644 index 2167c336..00000000 --- a/packages/xforms-engine/src/body/text/TextElementOutputPart.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { AnyTextElementDefinition } from './TextElementDefinition.ts'; -import { TextElementPart } from './TextElementPart.ts'; - -interface OutputElement extends Element { - readonly localName: 'output'; - - getAttribute(name: 'value'): string; - getAttribute(name: string): string | null; -} - -const isOutputElement = (element: Element): element is OutputElement => { - return element.localName === 'output' && element.hasAttribute('value'); -}; - -export class TextElementOutputPart extends TextElementPart<'output'> { - static from(context: AnyTextElementDefinition, element: Element): TextElementOutputPart | null { - if (isOutputElement(element)) { - return new this(context, element); - } - - return null; - } - - protected constructor(context: AnyTextElementDefinition, element: OutputElement) { - super('output', context, element.getAttribute('value')); - } -} diff --git a/packages/xforms-engine/src/body/text/TextElementPart.ts b/packages/xforms-engine/src/body/text/TextElementPart.ts deleted file mode 100644 index 1ade5d1a..00000000 --- a/packages/xforms-engine/src/body/text/TextElementPart.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { DependentExpression } from '../../expression/DependentExpression.ts'; -import type { AnyTextElementDefinition } from './TextElementDefinition.ts'; -import type { TextElementOutputPart } from './TextElementOutputPart.ts'; -import type { TextElementReferencePart } from './TextElementReferencePart.ts'; -import type { TextElementStaticPart } from './TextElementStaticPart.ts'; - -export type TextElementPartType = 'output' | 'reference' | 'static'; - -export abstract class TextElementPart< - Type extends TextElementPartType, -> extends DependentExpression<'string'> { - readonly stringValue?: string; - - constructor( - readonly type: Type, - context: AnyTextElementDefinition, - expression: string - ) { - super(context, 'string', expression, { - semanticDependencies: { - translations: type !== 'static', - }, - ignoreContextReference: true, - }); - } -} - -export type AnyTextElementPart = - | TextElementOutputPart - | TextElementReferencePart - | TextElementStaticPart; diff --git a/packages/xforms-engine/src/body/text/TextElementReferencePart.ts b/packages/xforms-engine/src/body/text/TextElementReferencePart.ts deleted file mode 100644 index 730eeef7..00000000 --- a/packages/xforms-engine/src/body/text/TextElementReferencePart.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { AnyTextElementDefinition, TextElement } from './TextElementDefinition.ts'; -import { TextElementPart } from './TextElementPart.ts'; - -export class TextElementReferencePart extends TextElementPart<'reference'> { - static from( - context: AnyTextElementDefinition, - element: TextElement - ): TextElementReferencePart | null { - const expression = element.getAttribute('ref'); - - if (expression == null) { - return null; - } - - return new this(context, expression); - } - - protected constructor(context: AnyTextElementDefinition, expression: string) { - super('reference', context, expression); - } -} diff --git a/packages/xforms-engine/src/body/text/TextElementStaticPart.ts b/packages/xforms-engine/src/body/text/TextElementStaticPart.ts deleted file mode 100644 index 72f4d289..00000000 --- a/packages/xforms-engine/src/body/text/TextElementStaticPart.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { AnyTextElementDefinition } from './TextElementDefinition.ts'; -import { TextElementPart } from './TextElementPart.ts'; - -const toStaticXPathExpression = (staticTextValue: string): string => { - const quote = staticTextValue.includes('"') ? "'" : '"'; - - if (staticTextValue.includes(quote)) { - // throw new Error('todo concat()'); - return 'todo(concat())'; - } - - return `${quote}${staticTextValue}${quote}`; -}; - -export class TextElementStaticPart extends TextElementPart<'static'> { - override readonly stringValue: string; - - constructor(context: AnyTextElementDefinition, node: Text) { - const stringValue = node.data; - const expression = toStaticXPathExpression(stringValue); - - super('static', context, expression); - - this.stringValue = stringValue; - } -} diff --git a/packages/xforms-engine/src/client/BaseNode.ts b/packages/xforms-engine/src/client/BaseNode.ts index 67193b47..c199623c 100644 --- a/packages/xforms-engine/src/client/BaseNode.ts +++ b/packages/xforms-engine/src/client/BaseNode.ts @@ -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 { /** @@ -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; /** @@ -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; } @@ -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; } diff --git a/packages/xforms-engine/src/client/GroupNode.ts b/packages/xforms-engine/src/client/GroupNode.ts index 637847ad..0d1eae77 100644 --- a/packages/xforms-engine/src/client/GroupNode.ts +++ b/packages/xforms-engine/src/client/GroupNode.ts @@ -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; @@ -34,4 +35,5 @@ export interface GroupNode extends BaseNode { readonly root: RootNode; readonly parent: GeneralParentNode; readonly currentState: GroupNodeState; + readonly validationState: AncestorNodeValidationState; } diff --git a/packages/xforms-engine/src/client/RepeatInstanceNode.ts b/packages/xforms-engine/src/client/RepeatInstanceNode.ts index 9692cd0c..41308ca2 100644 --- a/packages/xforms-engine/src/client/RepeatInstanceNode.ts +++ b/packages/xforms-engine/src/client/RepeatInstanceNode.ts @@ -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 @@ -42,4 +43,5 @@ export interface RepeatInstanceNode extends BaseNode { readonly parent: RepeatRangeNode; readonly currentState: RepeatInstanceNodeState; + readonly validationState: AncestorNodeValidationState; } diff --git a/packages/xforms-engine/src/client/RepeatRangeNode.ts b/packages/xforms-engine/src/client/RepeatRangeNode.ts index 966be56b..7696e48a 100644 --- a/packages/xforms-engine/src/client/RepeatRangeNode.ts +++ b/packages/xforms-engine/src/client/RepeatRangeNode.ts @@ -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; @@ -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; diff --git a/packages/xforms-engine/src/client/RootNode.ts b/packages/xforms-engine/src/client/RootNode.ts index be714374..2f5599b3 100644 --- a/packages/xforms-engine/src/client/RootNode.ts +++ b/packages/xforms-engine/src/client/RootNode.ts @@ -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 { /** @@ -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 diff --git a/packages/xforms-engine/src/client/SelectNode.ts b/packages/xforms-engine/src/client/SelectNode.ts index ecb105ab..345da54c 100644 --- a/packages/xforms-engine/src/client/SelectNode.ts +++ b/packages/xforms-engine/src/client/SelectNode.ts @@ -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 { @@ -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: diff --git a/packages/xforms-engine/src/client/StringNode.ts b/packages/xforms-engine/src/client/StringNode.ts index 740dcab6..03492d52 100644 --- a/packages/xforms-engine/src/client/StringNode.ts +++ b/packages/xforms-engine/src/client/StringNode.ts @@ -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; @@ -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. diff --git a/packages/xforms-engine/src/client/SubtreeNode.ts b/packages/xforms-engine/src/client/SubtreeNode.ts index d5dfe752..0901a477 100644 --- a/packages/xforms-engine/src/client/SubtreeNode.ts +++ b/packages/xforms-engine/src/client/SubtreeNode.ts @@ -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; @@ -55,4 +56,5 @@ export interface SubtreeNode extends BaseNode { readonly root: RootNode; readonly parent: GeneralParentNode; readonly currentState: SubtreeNodeState; + readonly validationState: AncestorNodeValidationState; } diff --git a/packages/xforms-engine/src/client/TextRange.ts b/packages/xforms-engine/src/client/TextRange.ts index 1d4d3dca..e2d814b3 100644 --- a/packages/xforms-engine/src/client/TextRange.ts +++ b/packages/xforms-engine/src/client/TextRange.ts @@ -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 + * `` 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 @@ -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: * @@ -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 { +export interface TextRange { + readonly origin: Origin; readonly role: Role; [Symbol.iterator](): Iterable; diff --git a/packages/xforms-engine/src/client/constants.ts b/packages/xforms-engine/src/client/constants.ts new file mode 100644 index 00000000..0fb7f1a1 --- /dev/null +++ b/packages/xforms-engine/src/client/constants.ts @@ -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; + +type ValidationTextDefaults = typeof VALIDATION_TEXT; + +export type ValidationTextDefault = ValidationTextDefaults[Role]; diff --git a/packages/xforms-engine/src/client/validation.ts b/packages/xforms-engine/src/client/validation.ts new file mode 100644 index 00000000..e29b009d --- /dev/null +++ b/packages/xforms-engine/src/client/validation.ts @@ -0,0 +1,199 @@ +import type { NodeID } from '../instance/identity.ts'; +import type { BaseNode, BaseNodeState } from './BaseNode.ts'; +import type { OpaqueReactiveObjectFactory } from './OpaqueReactiveObjectFactory.ts'; +import type { RootNode } from './RootNode.ts'; +import type { TextRange } from './TextRange.ts'; + +// This interface exists so that extensions can share JSDoc for `valid`. +interface BaseValidity { + /** + * Specifies the unambiguous validity state for each validity condition of a + * given node, or for the derived validity of any parent node whose descendants + * are validated. + * + * For {@link ValidationCondition | form-defined conditions}, validity is + * determined as follows: + * + * + * expression | state | blank | non-blank + * ------------:|:----------|:-------:|:---------: + * `constraint` | `true`\* | ✅ | ✅ + * `constraint` | `false` | ✅ | ❌ + * `required` | `false`\* | ✅ | ✅ + * `required` | `true` | ❌ | ✅ + * + * - \* = default (expression not defined) + * - ✅ = `valid: true` + * - ❌ = `valid: false` + */ + readonly valid: boolean; +} + +/** + * Form-defined conditions which determine node validity. + * + * @see {@link https://getodk.github.io/xforms-spec/#bind-attributes | `constraint` and `required` bind attributes} + */ +export type ValidationCondition = 'constraint' | 'required'; + +interface ValidationConditionMessageRoles { + readonly constraint: 'constraintMsg'; + readonly required: 'requiredMsg'; +} + +export type ValidationConditionMessageRole = + ValidationConditionMessageRoles[Condition]; + +/** + * Source of a condition's violation message. + * + * - Form-defined messages (specified by the + * {@link https://getodk.github.io/xforms-spec/#bind-attributes | `jr:constraintMsg` and `jr:requiredMsg`} + * attributes) will be favored when provided by the form, and will be + * translated according to the form's active language (where applicable). + * + * - Otherwise, an engine-defined message will be provided as a fallback. This + * fallback is provided mainly for API consistency, and may be referenced for + * testing purposes; user-facing clients are expected to provide fallback + * messaging language most appropriate for their user neeeds. Engine-defined + * fallback messages **are not translated**. They are intended to be used, if + * at all, as sentinel values when a form-defined message is not available. + */ +// eslint-disable-next-line @typescript-eslint/sort-type-constituents +export type ViolationMessageSource = 'form' | 'engine'; + +/** + * @see {@link ViolationMessage.asString} + */ +// prettier-ignore +type ViolationMessageAsString< + Source extends ViolationMessageSource, + Condition extends ValidationCondition, +> = + Source extends 'form' + ? string + : `Condition not satisfied: ${Condition}`; + +/** + * A violation message is provided for every violation of a form-defined + * {@link ValidationCondition}. + */ +export interface ViolationMessage< + Condition extends ValidationCondition, + Source extends ViolationMessageSource = ViolationMessageSource, +> extends TextRange> { + /** + * - Form-defined violation messages may produce arbitrary text. This text may + * be translated + * ({@link https://getodk.github.io/xforms-spec/#fn:jr:itext | `jr:itext`}), + * and it may be dynamic (translations may reference form state with + * {@link https://getodk.github.io/xforms-spec/#body-elements | ``}). + * + * - When a form-defined violation message is not available, an engine-defined + * message will be provided in its place. Engine-defined violation messages + * are statically defined (and therefore not presently translated by the + * engine). Their static value can also be referenced as a static type, by + * checking {@link isFallbackMessage}. + */ + get asString(): ViolationMessageAsString; +} + +export interface ConditionSatisfied extends BaseValidity { + readonly condition: Condition; + readonly valid: true; + readonly message: null; +} + +export interface ConditionViolation extends BaseValidity { + readonly condition: Condition; + readonly valid: false; + readonly message: ViolationMessage; +} + +export type ConditionValidation = + | ConditionSatisfied + | ConditionViolation; + +export type AnyViolation = ConditionViolation; + +/** + * Represents the validation state of a leaf (or value) node. + * + * Validity is computed for two conditions: + * + * - {@link constraint}: arbitrary form-defined condition which specifies + * whether a (non-blank) value is considered valid + * + * - {@link required}: when a node is required, the node must have a non-blank + * value to be considered valid + * + * Only one of these conditions can be violated (applicability is mutually + * exclusive). As such, {@link violation} provides a convenient way to determine + * whether a leaf/value node is valid with a single (null) check. + * + * @see {@link BaseValidity.valid} for additional details on how these + * conditions are evaluated (and how they interact with one another). + */ +export interface LeafNodeValidationState { + get constraint(): ConditionValidation<'constraint'>; + get required(): ConditionValidation<'required'>; + + /** + * Violations are mutually exclusive: + * + * - {@link constraint} can only be violated by a non-blank value + * - {@link required} can only be violated by a blank value + * + * As such, at most one violation can be present. If none is present, + * the node is considered valid. + */ + get violation(): AnyViolation | null; +} + +/** + * Provides a reference to any leaf/value node which currently violates either + * of its validity conditions. + * + * Any client can safely assume: + * + * - {@link nodeId} will be a stable reference to a node with the same + * {@link BaseNode.nodeId | `nodeId`}. + * + * - {@link node} will have reference equality to the same node object, within + * the active form instance's {@link RootNode} tree + * + * - {@link reference} will be a **current** reference to the same node object's + * **computed** {@link BaseNodeState.reference | `currentState.reference`} + * + * Any client utilizing the engine's reactive APIs (having provided an + * {@link OpaqueReactiveObjectFactory}) can safely assume that {@link reference} + * will be recomputed and updated in tandem with the affected node's own + * computed `currentState.reference` as well. + * + * @todo this type currently exposes multiple ways to reference the affected + * node. This is intended to maximize flexibility: it's not yet clear how + * clients will be best served by which reference mechanism. It is expected that + * each property will be directly computed from the affected node. + */ +export interface DescendantNodeViolationReference { + readonly nodeId: NodeID; + + get reference(): string; + get violation(): AnyViolation; +} + +/** + * Provides access from any ancestor/parent node, to identify any validity + * violations present on any of its leaf/value node descendants. + * + * @see {@link DescendantNodeViolationReference} for details on how descendants + * may be referenced when such a violation is present. + */ +export interface AncestorNodeValidationState { + get violations(): readonly DescendantNodeViolationReference[]; +} + +// prettier-ignore +export type NodeValidationState = + | AncestorNodeValidationState + | LeafNodeValidationState; diff --git a/packages/xforms-engine/src/index.ts b/packages/xforms-engine/src/index.ts index 60abdac0..17e8af13 100644 --- a/packages/xforms-engine/src/index.ts +++ b/packages/xforms-engine/src/index.ts @@ -14,6 +14,7 @@ export type * from './client/SelectNode.ts'; export type * from './client/StringNode.ts'; export type * from './client/SubtreeNode.ts'; export type * from './client/TextRange.ts'; +export * as constants from './client/constants.ts'; export type { AnyChildNode, AnyLeafNode, @@ -23,6 +24,7 @@ export type { GeneralParentNode, } from './client/hierarchy.ts'; export type * from './client/index.ts'; +export type * from './client/validation.ts'; // TODO: notwithstanding potential conflicts with parallel work on `web-forms` // (former `ui-vue`), these are the last remaining references **outside of diff --git a/packages/xforms-engine/src/instance/Group.ts b/packages/xforms-engine/src/instance/Group.ts index 2b0e344f..44b08f91 100644 --- a/packages/xforms-engine/src/instance/Group.ts +++ b/packages/xforms-engine/src/instance/Group.ts @@ -1,6 +1,7 @@ import type { Accessor } from 'solid-js'; import type { GroupDefinition, GroupNode, GroupNodeAppearances } from '../client/GroupNode.ts'; -import type { TextRange } from '../index.ts'; +import type { TextRange } from '../client/TextRange.ts'; +import type { AncestorNodeValidationState } from '../client/validation.ts'; import type { ChildrenState } from '../lib/reactivity/createChildrenState.ts'; import { createChildrenState } from '../lib/reactivity/createChildrenState.ts'; import type { MaterializedChildren } from '../lib/reactivity/materializeCurrentStateChildren.ts'; @@ -10,6 +11,7 @@ import type { EngineState } from '../lib/reactivity/node-state/createEngineState import type { SharedNodeState } from '../lib/reactivity/node-state/createSharedNodeState.ts'; import { createSharedNodeState } from '../lib/reactivity/node-state/createSharedNodeState.ts'; import { createNodeLabel } from '../lib/reactivity/text/createNodeLabel.ts'; +import { createAggregatedViolations } from '../lib/reactivity/validation/createAggregatedViolations.ts'; import type { DescendantNodeSharedStateSpec } from './abstract/DescendantNode.ts'; import { DescendantNode } from './abstract/DescendantNode.ts'; import { buildChildren } from './children.ts'; @@ -41,6 +43,7 @@ export class Group readonly nodeType = 'group'; readonly appearances: GroupNodeAppearances; readonly currentState: MaterializedChildren, GeneralChildNode>; + readonly validationState: AncestorNodeValidationState; constructor(parent: GeneralParentNode, definition: GroupDefinition) { super(parent, definition); @@ -51,6 +54,10 @@ export class Group this.childrenState = childrenState; + const sharedStateOptions = { + clientStateFactory: this.engineConfig.stateFactory, + }; + const state = createSharedNodeState( this.scope, { @@ -65,9 +72,7 @@ export class Group valueOptions: null, value: null, }, - { - clientStateFactory: this.engineConfig.stateFactory, - } + sharedStateOptions ); this.state = state; @@ -79,6 +84,7 @@ export class Group ); childrenState.setChildren(buildChildren(this)); + this.validationState = createAggregatedViolations(this, sharedStateOptions); } getChildren(): readonly GeneralChildNode[] { diff --git a/packages/xforms-engine/src/instance/RepeatInstance.ts b/packages/xforms-engine/src/instance/RepeatInstance.ts index 4c313abe..2c74dab5 100644 --- a/packages/xforms-engine/src/instance/RepeatInstance.ts +++ b/packages/xforms-engine/src/instance/RepeatInstance.ts @@ -5,7 +5,8 @@ import type { RepeatInstanceNode, RepeatInstanceNodeAppearances, } from '../client/RepeatInstanceNode.ts'; -import type { TextRange } from '../index.ts'; +import type { TextRange } from '../client/TextRange.ts'; +import type { AncestorNodeValidationState } from '../client/validation.ts'; import type { ChildrenState } from '../lib/reactivity/createChildrenState.ts'; import { createChildrenState } from '../lib/reactivity/createChildrenState.ts'; import type { MaterializedChildren } from '../lib/reactivity/materializeCurrentStateChildren.ts'; @@ -15,6 +16,7 @@ import type { EngineState } from '../lib/reactivity/node-state/createEngineState import type { SharedNodeState } from '../lib/reactivity/node-state/createSharedNodeState.ts'; import { createSharedNodeState } from '../lib/reactivity/node-state/createSharedNodeState.ts'; import { createNodeLabel } from '../lib/reactivity/text/createNodeLabel.ts'; +import { createAggregatedViolations } from '../lib/reactivity/validation/createAggregatedViolations.ts'; import type { RepeatRange } from './RepeatRange.ts'; import type { DescendantNodeSharedStateSpec } from './abstract/DescendantNode.ts'; import { DescendantNode } from './abstract/DescendantNode.ts'; @@ -87,6 +89,7 @@ export class RepeatInstance CurrentState, GeneralChildNode >; + readonly validationState: AncestorNodeValidationState; constructor( override readonly parent: RepeatRange, @@ -116,6 +119,10 @@ export class RepeatInstance this.currentIndex = currentIndex; + const sharedStateOptions = { + clientStateFactory: this.engineConfig.stateFactory, + }; + const state = createSharedNodeState( this.scope, { @@ -131,9 +138,7 @@ export class RepeatInstance valueOptions: null, value: null, }, - { - clientStateFactory: this.engineConfig.stateFactory, - } + sharedStateOptions ); this.state = state; @@ -166,6 +171,7 @@ export class RepeatInstance }); childrenState.setChildren(buildChildren(this)); + this.validationState = createAggregatedViolations(this, sharedStateOptions); } protected override initializeContextNode(parentContextNode: Element, nodeName: string): Element { diff --git a/packages/xforms-engine/src/instance/RepeatRange.ts b/packages/xforms-engine/src/instance/RepeatRange.ts index e0c12317..24fe6071 100644 --- a/packages/xforms-engine/src/instance/RepeatRange.ts +++ b/packages/xforms-engine/src/instance/RepeatRange.ts @@ -1,6 +1,8 @@ import { insertAtIndex } from '@getodk/common/lib/array/insert.ts'; import type { Accessor } from 'solid-js'; import type { RepeatRangeNode, RepeatRangeNodeAppearances } from '../client/RepeatRangeNode.ts'; +import type { TextRange } from '../client/TextRange.ts'; +import type { AncestorNodeValidationState } from '../client/validation.ts'; import type { ChildrenState } from '../lib/reactivity/createChildrenState.ts'; import { createChildrenState } from '../lib/reactivity/createChildrenState.ts'; import { createComputedExpression } from '../lib/reactivity/createComputedExpression.ts'; @@ -11,6 +13,7 @@ import type { EngineState } from '../lib/reactivity/node-state/createEngineState import type { SharedNodeState } from '../lib/reactivity/node-state/createSharedNodeState.ts'; import { createSharedNodeState } from '../lib/reactivity/node-state/createSharedNodeState.ts'; import { createNodeLabel } from '../lib/reactivity/text/createNodeLabel.ts'; +import { createAggregatedViolations } from '../lib/reactivity/validation/createAggregatedViolations.ts'; import type { RepeatRangeDefinition } from '../model/RepeatRangeDefinition.ts'; import type { RepeatDefinition } from './RepeatInstance.ts'; import { RepeatInstance } from './RepeatInstance.ts'; @@ -21,7 +24,6 @@ import type { 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'; -import type { TextRange } from './text/TextRange.ts'; interface RepeatRangeStateSpec extends DescendantNodeSharedStateSpec { readonly hint: null; @@ -164,6 +166,7 @@ export class RepeatRange readonly appearances: RepeatRangeNodeAppearances; readonly currentState: MaterializedChildren, RepeatInstance>; + readonly validationState: AncestorNodeValidationState; constructor(parent: GeneralParentNode, definition: RepeatRangeDefinition) { super(parent, definition); @@ -196,6 +199,10 @@ export class RepeatRange definition.bind.relevant ); + const sharedStateOptions = { + clientStateFactory: this.engineConfig.stateFactory, + }; + const state = createSharedNodeState( this.scope, { @@ -210,9 +217,7 @@ export class RepeatRange valueOptions: null, value: null, }, - { - clientStateFactory: this.engineConfig.stateFactory, - } + sharedStateOptions ); this.state = state; @@ -228,6 +233,7 @@ export class RepeatRange this.addInstances(afterIndex, 1, instanceDefinition); }); + this.validationState = createAggregatedViolations(this, sharedStateOptions); } private getLastIndex(): number { diff --git a/packages/xforms-engine/src/instance/Root.ts b/packages/xforms-engine/src/instance/Root.ts index ab262118..127bb067 100644 --- a/packages/xforms-engine/src/instance/Root.ts +++ b/packages/xforms-engine/src/instance/Root.ts @@ -5,6 +5,7 @@ import type { XFormDOM } from '../XFormDOM.ts'; import type { BodyClassList } from '../body/BodyDefinition.ts'; import type { ActiveLanguage, FormLanguage, FormLanguages } from '../client/FormLanguage.ts'; import type { RootNode } from '../client/RootNode.ts'; +import type { AncestorNodeValidationState } from '../client/validation.ts'; import type { ChildrenState } from '../lib/reactivity/createChildrenState.ts'; import { createChildrenState } from '../lib/reactivity/createChildrenState.ts'; import type { MaterializedChildren } from '../lib/reactivity/materializeCurrentStateChildren.ts'; @@ -13,6 +14,7 @@ import type { CurrentState } from '../lib/reactivity/node-state/createCurrentSta import type { EngineState } from '../lib/reactivity/node-state/createEngineState.ts'; import type { SharedNodeState } from '../lib/reactivity/node-state/createSharedNodeState.ts'; import { createSharedNodeState } from '../lib/reactivity/node-state/createSharedNodeState.ts'; +import { createAggregatedViolations } from '../lib/reactivity/validation/createAggregatedViolations.ts'; import type { RootDefinition } from '../model/RootDefinition.ts'; import { InstanceNode } from './abstract/InstanceNode.ts'; import { buildChildren } from './children.ts'; @@ -114,6 +116,7 @@ export class Root readonly appearances = null; readonly classes: BodyClassList; readonly currentState: MaterializedChildren, GeneralChildNode>; + readonly validationState: AncestorNodeValidationState; protected readonly instanceDOM: XFormDOM; @@ -152,6 +155,10 @@ export class Root const evaluator = instanceDOM.primaryInstanceEvaluator; const { translations } = evaluator; const { defaultLanguage, languages } = getInitialLanguageState(translations); + const sharedStateOptions = { + clientStateFactory: this.engineConfig.stateFactory, + }; + const state = createSharedNodeState( this.scope, { @@ -166,9 +173,7 @@ export class Root value: null, children: childrenState.childIds, }, - { - clientStateFactory: engineConfig.stateFactory, - } + sharedStateOptions ); this.state = state; @@ -189,6 +194,7 @@ export class Root this.languages = languages; childrenState.setChildren(buildChildren(this)); + this.validationState = createAggregatedViolations(this, sharedStateOptions); } getChildren(): readonly GeneralChildNode[] { diff --git a/packages/xforms-engine/src/instance/SelectField.ts b/packages/xforms-engine/src/instance/SelectField.ts index a0a07143..e31176ef 100644 --- a/packages/xforms-engine/src/instance/SelectField.ts +++ b/packages/xforms-engine/src/instance/SelectField.ts @@ -3,7 +3,8 @@ import type { Accessor } from 'solid-js'; import { untrack } from 'solid-js'; import type { AnySelectDefinition } from '../body/control/select/SelectDefinition.ts'; import type { SelectItem, SelectNode, SelectNodeAppearances } from '../client/SelectNode.ts'; -import type { TextRange } from '../index.ts'; +import type { TextRange } from '../client/TextRange.ts'; +import type { AnyViolation, LeafNodeValidationState } from '../client/validation.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'; @@ -13,6 +14,8 @@ import { createSharedNodeState } from '../lib/reactivity/node-state/createShared import { createFieldHint } from '../lib/reactivity/text/createFieldHint.ts'; import { createNodeLabel } from '../lib/reactivity/text/createNodeLabel.ts'; import type { SimpleAtomicState } from '../lib/reactivity/types.ts'; +import type { SharedValidationState } from '../lib/reactivity/validation/createValidation.ts'; +import { createValidationState } from '../lib/reactivity/validation/createValidation.ts'; import type { ValueNodeDefinition } from '../model/ValueNodeDefinition.ts'; import type { Root } from './Root.ts'; import type { DescendantNodeStateSpec } from './abstract/DescendantNode.ts'; @@ -20,6 +23,7 @@ import { DescendantNode } from './abstract/DescendantNode.ts'; import type { GeneralParentNode } from './hierarchy.ts'; 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'; export interface SelectFieldDefinition extends ValueNodeDefinition { @@ -40,9 +44,11 @@ export class SelectField SelectNode, EvaluationContext, SubscribableDependency, + ValidationContext, ValueContext { private readonly selectExclusive: boolean; + private readonly validation: SharedValidationState; // InstanceNode protected readonly state: SharedNodeState; @@ -53,6 +59,10 @@ export class SelectField readonly appearances: SelectNodeAppearances; readonly currentState: CurrentState; + get validationState(): LeafNodeValidationState { + return this.validation.currentState; + } + // ValueContext readonly encodeValue = (runtimeValue: readonly SelectItem[]): string => { const itemValues = new Set(runtimeValue.map(({ value }) => value)); @@ -90,6 +100,10 @@ export class SelectField this.getValueOptions = valueOptions; + const sharedStateOptions = { + clientStateFactory: this.engineConfig.stateFactory, + }; + const state = createSharedNodeState( this.scope, { @@ -104,14 +118,22 @@ export class SelectField value: createValueState(this), valueOptions, }, - { - clientStateFactory: this.engineConfig.stateFactory, - } + sharedStateOptions ); this.state = state; this.engineState = state.engineState; this.currentState = state.currentState; + this.validation = createValidationState(this, sharedStateOptions); + } + + getViolation(): AnyViolation | null { + // Read engine state to ensure reactivity in engine, Solid-based clients + this.validation.engineState.violation; + + // Read/return client state to ensure client reactivity, regardless of + // client's reactive implementation + return this.validationState.violation; } protected getSelectItemsByValue( @@ -201,4 +223,9 @@ export class SelectField getChildren(): readonly [] { return []; } + + // ValidationContext + isBlank(): boolean { + return this.engineState.value.length === 0; + } } diff --git a/packages/xforms-engine/src/instance/StringField.ts b/packages/xforms-engine/src/instance/StringField.ts index 1bf7190d..cb8ee4e9 100644 --- a/packages/xforms-engine/src/instance/StringField.ts +++ b/packages/xforms-engine/src/instance/StringField.ts @@ -2,7 +2,8 @@ import { identity } from '@getodk/common/lib/identity.ts'; import type { Accessor } from 'solid-js'; import type { InputDefinition } from '../body/control/InputDefinition.ts'; import type { StringNode, StringNodeAppearances } from '../client/StringNode.ts'; -import type { TextRange } from '../index.ts'; +import type { TextRange } from '../client/TextRange.ts'; +import type { AnyViolation, LeafNodeValidationState } from '../client/validation.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'; @@ -11,6 +12,8 @@ import { createSharedNodeState } from '../lib/reactivity/node-state/createShared import { createFieldHint } from '../lib/reactivity/text/createFieldHint.ts'; import { createNodeLabel } from '../lib/reactivity/text/createNodeLabel.ts'; import type { SimpleAtomicState } from '../lib/reactivity/types.ts'; +import type { SharedValidationState } from '../lib/reactivity/validation/createValidation.ts'; +import { createValidationState } from '../lib/reactivity/validation/createValidation.ts'; import type { ValueNodeDefinition } from '../model/ValueNodeDefinition.ts'; import type { Root } from './Root.ts'; import type { DescendantNodeStateSpec } from './abstract/DescendantNode.ts'; @@ -18,6 +21,7 @@ import { DescendantNode } from './abstract/DescendantNode.ts'; import type { GeneralParentNode } from './hierarchy.ts'; 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'; export interface StringFieldDefinition extends ValueNodeDefinition { @@ -34,8 +38,14 @@ interface StringFieldStateSpec extends DescendantNodeStateSpec { export class StringField extends DescendantNode - implements StringNode, EvaluationContext, SubscribableDependency, ValueContext + implements + StringNode, + EvaluationContext, + SubscribableDependency, + ValidationContext, + ValueContext { + private readonly validation: SharedValidationState; protected readonly state: SharedNodeState; // InstanceNode @@ -46,6 +56,10 @@ export class StringField readonly appearances: StringNodeAppearances; readonly currentState: CurrentState; + get validationState(): LeafNodeValidationState { + return this.validation.currentState; + } + // ValueContext readonly encodeValue = identity; @@ -56,6 +70,10 @@ export class StringField this.appearances = (definition.bodyElement?.appearances ?? null) as StringNodeAppearances; + const sharedStateOptions = { + clientStateFactory: this.engineConfig.stateFactory, + }; + const state = createSharedNodeState( this.scope, { @@ -70,14 +88,22 @@ export class StringField valueOptions: null, value: createValueState(this), }, - { - clientStateFactory: this.engineConfig.stateFactory, - } + sharedStateOptions ); this.state = state; this.engineState = state.engineState; this.currentState = state.currentState; + this.validation = createValidationState(this, sharedStateOptions); + } + + getViolation(): AnyViolation | null { + return this.validation.engineState.violation; + } + + // ValidationContext + isBlank(): boolean { + return this.engineState.value === ''; } // InstanceNode diff --git a/packages/xforms-engine/src/instance/Subtree.ts b/packages/xforms-engine/src/instance/Subtree.ts index 03440f5c..ea05774a 100644 --- a/packages/xforms-engine/src/instance/Subtree.ts +++ b/packages/xforms-engine/src/instance/Subtree.ts @@ -1,5 +1,6 @@ import { type Accessor } from 'solid-js'; import type { SubtreeDefinition, SubtreeNode } from '../client/SubtreeNode.ts'; +import type { AncestorNodeValidationState } from '../client/validation.ts'; import type { ChildrenState } from '../lib/reactivity/createChildrenState.ts'; import { createChildrenState } from '../lib/reactivity/createChildrenState.ts'; import type { MaterializedChildren } from '../lib/reactivity/materializeCurrentStateChildren.ts'; @@ -8,6 +9,7 @@ import type { CurrentState } from '../lib/reactivity/node-state/createCurrentSta import type { EngineState } from '../lib/reactivity/node-state/createEngineState.ts'; import type { SharedNodeState } from '../lib/reactivity/node-state/createSharedNodeState.ts'; import { createSharedNodeState } from '../lib/reactivity/node-state/createSharedNodeState.ts'; +import { createAggregatedViolations } from '../lib/reactivity/validation/createAggregatedViolations.ts'; import type { DescendantNodeSharedStateSpec } from './abstract/DescendantNode.ts'; import { DescendantNode } from './abstract/DescendantNode.ts'; import { buildChildren } from './children.ts'; @@ -38,6 +40,7 @@ export class Subtree readonly nodeType = 'subtree'; readonly appearances = null; readonly currentState: MaterializedChildren, GeneralChildNode>; + readonly validationState: AncestorNodeValidationState; constructor(parent: GeneralParentNode, definition: SubtreeDefinition) { super(parent, definition); @@ -46,6 +49,10 @@ export class Subtree this.childrenState = childrenState; + const sharedStateOptions = { + clientStateFactory: this.engineConfig.stateFactory, + }; + const state = createSharedNodeState( this.scope, { @@ -60,9 +67,7 @@ export class Subtree valueOptions: null, value: null, }, - { - clientStateFactory: this.engineConfig.stateFactory, - } + sharedStateOptions ); this.state = state; @@ -74,6 +79,7 @@ export class Subtree ); childrenState.setChildren(buildChildren(this)); + this.validationState = createAggregatedViolations(this, sharedStateOptions); } getChildren(): readonly GeneralChildNode[] { diff --git a/packages/xforms-engine/src/instance/abstract/DescendantNode.ts b/packages/xforms-engine/src/instance/abstract/DescendantNode.ts index 468fa4aa..163db65f 100644 --- a/packages/xforms-engine/src/instance/abstract/DescendantNode.ts +++ b/packages/xforms-engine/src/instance/abstract/DescendantNode.ts @@ -96,7 +96,7 @@ export abstract class DescendantNode< return this.isSelfRelevant(); }; - protected readonly isRequired: Accessor; + readonly isRequired: Accessor; readonly root: Root; readonly evaluator: XFormsXPathEvaluator; diff --git a/packages/xforms-engine/src/instance/abstract/InstanceNode.ts b/packages/xforms-engine/src/instance/abstract/InstanceNode.ts index a97be70d..9a512522 100644 --- a/packages/xforms-engine/src/instance/abstract/InstanceNode.ts +++ b/packages/xforms-engine/src/instance/abstract/InstanceNode.ts @@ -3,6 +3,7 @@ import type { Accessor, Signal } from 'solid-js'; import type { BaseNode } from '../../client/BaseNode.ts'; import type { NodeAppearances } from '../../client/NodeAppearances.ts'; import type { InstanceNodeType } from '../../client/node-types.ts'; +import type { NodeValidationState } from '../../client/validation.ts'; import type { TextRange } from '../../index.ts'; import type { MaterializedChildren } from '../../lib/reactivity/materializeCurrentStateChildren.ts'; import type { CurrentState } from '../../lib/reactivity/node-state/createCurrentState.ts'; @@ -117,6 +118,8 @@ export abstract class InstanceNode< abstract readonly currentState: InstanceNodeCurrentState; + abstract readonly validationState: NodeValidationState; + // BaseNode: structural abstract readonly root: Root; diff --git a/packages/xforms-engine/src/instance/hierarchy.ts b/packages/xforms-engine/src/instance/hierarchy.ts index d1868f94..784ade1e 100644 --- a/packages/xforms-engine/src/instance/hierarchy.ts +++ b/packages/xforms-engine/src/instance/hierarchy.ts @@ -52,3 +52,9 @@ export type GeneralChildNode = | RepeatRange | StringField | SelectField; + +// prettier-ignore +export type AnyValueNode = + // eslint-disable-next-line @typescript-eslint/sort-type-constituents + | StringField + | SelectField; diff --git a/packages/xforms-engine/src/instance/internal-api/ValidationContext.ts b/packages/xforms-engine/src/instance/internal-api/ValidationContext.ts new file mode 100644 index 00000000..ffaa8606 --- /dev/null +++ b/packages/xforms-engine/src/instance/internal-api/ValidationContext.ts @@ -0,0 +1,23 @@ +import type { BindComputation } from '../../model/BindComputation.ts'; +import type { MessageDefinition } from '../../parse/text/MessageDefinition.ts'; +import type { EvaluationContext } from './EvaluationContext.ts'; +import type { SubscribableDependency } from './SubscribableDependency.ts'; + +interface ValidationContextDefinitionBind { + readonly constraint: BindComputation<'constraint'>; + readonly constraintMsg: MessageDefinition<'constraintMsg'> | null; + readonly required: BindComputation<'required'>; + readonly requiredMsg: MessageDefinition<'requiredMsg'> | null; +} + +interface ValidationContextDefinition { + readonly bind: ValidationContextDefinitionBind; +} + +export interface ValidationContext extends EvaluationContext, SubscribableDependency { + readonly definition: ValidationContextDefinition; + + isRelevant(): boolean; + isRequired(): boolean; + isBlank(): boolean; +} diff --git a/packages/xforms-engine/src/instance/text/TextRange.ts b/packages/xforms-engine/src/instance/text/TextRange.ts index 740f5412..d4564694 100644 --- a/packages/xforms-engine/src/instance/text/TextRange.ts +++ b/packages/xforms-engine/src/instance/text/TextRange.ts @@ -1,9 +1,14 @@ -import type { TextRange as ClientTextRange, TextChunk } from '../../client/TextRange.ts'; +import type { + TextRange as ClientTextRange, + TextChunk, + TextOrigin, + TextRole, +} from '../../client/TextRange.ts'; import { FormattedTextStub } from './FormattedTextStub.ts'; -export type TextRole = 'hint' | 'label'; - -export class TextRange implements ClientTextRange { +export class TextRange + implements ClientTextRange +{ *[Symbol.iterator]() { yield* this.chunks; } @@ -17,6 +22,7 @@ export class TextRange implements ClientTextRange { } constructor( + readonly origin: Origin, readonly role: Role, protected readonly chunks: readonly TextChunk[] ) {} diff --git a/packages/xforms-engine/src/lib/reactivity/createComputedExpression.ts b/packages/xforms-engine/src/lib/reactivity/createComputedExpression.ts index 5a9debf3..bef3c83e 100644 --- a/packages/xforms-engine/src/lib/reactivity/createComputedExpression.ts +++ b/packages/xforms-engine/src/lib/reactivity/createComputedExpression.ts @@ -71,9 +71,14 @@ type ComputedExpression = Accessor< EvaluatedExpression >; +interface CreateComputedExpressionOptions { + readonly arbitraryDependencies?: readonly SubscribableDependency[]; +} + export const createComputedExpression = ( context: EvaluationContext, - dependentExpression: DependentExpression + dependentExpression: DependentExpression, + options: CreateComputedExpressionOptions = {} ): ComputedExpression => { const { contextNode, evaluator, root, scope } = context; const { expression, isTranslated, resultType } = dependentExpression; @@ -85,26 +90,24 @@ export const createComputedExpression = { return dependencyReferences.flatMap((reference) => { return context.getSubscribableDependenciesByReference(reference) ?? []; }); }); - let getDependencies: Accessor; + return createMemo(() => { + if (isTranslated) { + root.subscribe(); + } - if (isTranslated) { - getDependencies = createMemo(() => { - return [root, ...getReferencedDependencies()]; + arbitraryDependencies.forEach((dependency) => { + dependency.subscribe(); }); - } else { - getDependencies = getReferencedDependencies; - } - - return createMemo(() => { - const dependencies = getDependencies(); - dependencies.forEach((dependency) => { + getReferencedDependencies().forEach((dependency) => { dependency.subscribe(); }); diff --git a/packages/xforms-engine/src/lib/reactivity/createSelectItems.ts b/packages/xforms-engine/src/lib/reactivity/createSelectItems.ts index a35bfc5a..838bd132 100644 --- a/packages/xforms-engine/src/lib/reactivity/createSelectItems.ts +++ b/packages/xforms-engine/src/lib/reactivity/createSelectItems.ts @@ -4,6 +4,7 @@ import type { Accessor } from 'solid-js'; import { createMemo } from 'solid-js'; import type { ItemDefinition } from '../../body/control/select/ItemDefinition.ts'; import type { ItemsetDefinition } from '../../body/control/select/ItemsetDefinition.ts'; +import type { TextRange as ClientTextRange } from '../../client/TextRange.ts'; import type { SelectItem } from '../../index.ts'; import type { SelectField } from '../../instance/SelectField.ts'; import type { @@ -11,20 +12,31 @@ import type { EvaluationContextRoot, } from '../../instance/internal-api/EvaluationContext.ts'; import type { SubscribableDependency } from '../../instance/internal-api/SubscribableDependency.ts'; -import type { TextRange } from '../../instance/text/TextRange.ts'; +import { TextChunk } from '../../instance/text/TextChunk.ts'; +import { TextRange } from '../../instance/text/TextRange.ts'; import { createComputedExpression } from './createComputedExpression.ts'; import type { ReactiveScope } from './scope.ts'; import { createTextRange } from './text/createTextRange.ts'; +type DerivedItemLabel = ClientTextRange<'item-label', 'form-derived'>; + +const derivedItemLabel = (context: EvaluationContext, value: string): DerivedItemLabel => { + const chunk = new TextChunk(context.root, 'static', value); + + return new TextRange('form-derived', 'item-label', [chunk]); +}; + const createSelectItemLabel = ( context: EvaluationContext, definition: ItemDefinition -): Accessor> => { +): Accessor> => { const { label, value } = definition; - return createTextRange(context, 'label', label, { - fallbackValue: value, - }); + if (label == null) { + return () => derivedItemLabel(context, value); + } + + return createTextRange(context, 'item-label', label); }; const createTranslatedStaticSelectItems = ( @@ -73,25 +85,20 @@ const createSelectItemsetItemLabel = ( context: EvaluationContext, definition: ItemsetDefinition, itemValue: Accessor -): Accessor> => { +): Accessor> => { const { label } = definition; if (label == null) { return createMemo(() => { - const value = itemValue(); - const staticValueLabel = createTextRange(context, 'label', label, { - fallbackValue: value, - }); - - return staticValueLabel(); + return derivedItemLabel(context, itemValue()); }); } - return createTextRange(context, 'label', label); + return createTextRange(context, 'item-label', label); }; interface ItemsetItem { - label(): TextRange<'label'>; + label(): ClientTextRange<'item-label'>; value(): string; } diff --git a/packages/xforms-engine/src/lib/reactivity/node-state/createSharedNodeState.ts b/packages/xforms-engine/src/lib/reactivity/node-state/createSharedNodeState.ts index ff9bb754..d5f4dd4c 100644 --- a/packages/xforms-engine/src/lib/reactivity/node-state/createSharedNodeState.ts +++ b/packages/xforms-engine/src/lib/reactivity/node-state/createSharedNodeState.ts @@ -31,7 +31,7 @@ export interface SharedNodeState { readonly setProperty: SetEnginePropertyState; } -interface SharedNodeStateOptions< +export interface SharedNodeStateOptions< Factory extends OpaqueReactiveObjectFactory, Spec extends StateSpec, > { diff --git a/packages/xforms-engine/src/lib/reactivity/text/createFieldHint.ts b/packages/xforms-engine/src/lib/reactivity/text/createFieldHint.ts index a1940f93..3f9c7b04 100644 --- a/packages/xforms-engine/src/lib/reactivity/text/createFieldHint.ts +++ b/packages/xforms-engine/src/lib/reactivity/text/createFieldHint.ts @@ -1,6 +1,6 @@ import { type Accessor } from 'solid-js'; +import type { TextRange } from '../../../client/TextRange.ts'; import type { EvaluationContext } from '../../../instance/internal-api/EvaluationContext.ts'; -import { TextRange } from '../../../instance/text/TextRange.ts'; import type { ValueNodeDefinition } from '../../../model/ValueNodeDefinition.ts'; import { createTextRange } from './createTextRange.ts'; @@ -10,7 +10,9 @@ export const createFieldHint = ( ): Accessor | null> => { const hintDefinition = definition.bodyElement?.hint ?? null; - return createTextRange(context, 'hint', hintDefinition, { - fallbackValue: null, - }); + if (hintDefinition == null) { + return () => null; + } + + return createTextRange(context, 'hint', hintDefinition); }; diff --git a/packages/xforms-engine/src/lib/reactivity/text/createNodeLabel.ts b/packages/xforms-engine/src/lib/reactivity/text/createNodeLabel.ts index 6d2c151e..2371e9ce 100644 --- a/packages/xforms-engine/src/lib/reactivity/text/createNodeLabel.ts +++ b/packages/xforms-engine/src/lib/reactivity/text/createNodeLabel.ts @@ -1,6 +1,6 @@ import { type Accessor } from 'solid-js'; +import type { TextRange } from '../../../client/TextRange.ts'; import type { EvaluationContext } from '../../../instance/internal-api/EvaluationContext.ts'; -import { TextRange } from '../../../instance/text/TextRange.ts'; import type { AnyNodeDefinition } from '../../../model/NodeDefinition.ts'; import { createTextRange } from './createTextRange.ts'; @@ -10,7 +10,9 @@ export const createNodeLabel = ( ): Accessor | null> => { const labelDefinition = definition.bodyElement?.label ?? null; - return createTextRange(context, 'label', labelDefinition, { - fallbackValue: null, - }); + if (labelDefinition == null) { + return () => null; + } + + return createTextRange(context, 'label', labelDefinition); }; diff --git a/packages/xforms-engine/src/lib/reactivity/text/createTextRange.ts b/packages/xforms-engine/src/lib/reactivity/text/createTextRange.ts index 3a5816fb..5ace60c9 100644 --- a/packages/xforms-engine/src/lib/reactivity/text/createTextRange.ts +++ b/packages/xforms-engine/src/lib/reactivity/text/createTextRange.ts @@ -1,23 +1,13 @@ -import type { CollectionValues } from '@getodk/common/types/collections/CollectionValues.ts'; -import { createMemo, type Accessor } from 'solid-js'; -import type { - TextElementChild, - TextElementDefinition, -} from '../../../body/text/TextElementDefinition.ts'; -import type { TextElementReferencePart } from '../../../body/text/TextElementReferencePart.ts'; -import type { TextChunkSource } from '../../../client/TextRange.ts'; +import type { Accessor } from 'solid-js'; +import { createMemo } from 'solid-js'; +import type { TextChunkSource, TextRole } from '../../../client/TextRange.ts'; import type { EvaluationContext } from '../../../instance/internal-api/EvaluationContext.ts'; import { TextChunk } from '../../../instance/text/TextChunk.ts'; -import { TextRange, type TextRole } from '../../../instance/text/TextRange.ts'; +import { TextRange } from '../../../instance/text/TextRange.ts'; +import type { AnyTextChunkDefinition } from '../../../parse/text/abstract/TextChunkDefinition.ts'; +import type { TextRangeDefinition } from '../../../parse/text/abstract/TextRangeDefinition.ts'; import { createComputedExpression } from '../createComputedExpression.ts'; -// prettier-ignore -type TextSources = - | readonly [TextElementReferencePart] - | readonly TextElementChild[]; - -type TextSource = CollectionValues; - interface TextChunkComputation { readonly source: TextChunkSource; readonly getText: Accessor; @@ -25,21 +15,20 @@ interface TextChunkComputation { const createComputedTextChunk = ( context: EvaluationContext, - textSource: TextSource + textSource: AnyTextChunkDefinition ): TextChunkComputation => { - const { type } = textSource; + const { source } = textSource; - if (type === 'static') { + if (source === 'static') { const { stringValue } = textSource; return { - source: type, + source, getText: () => stringValue, }; } return context.scope.runTask(() => { - const source: TextChunkSource = type === 'reference' ? 'itext' : type; const getText = createComputedExpression(context, textSource); return { @@ -51,7 +40,7 @@ const createComputedTextChunk = ( const createTextChunks = ( context: EvaluationContext, - textSources: TextSources + textSources: readonly AnyTextChunkDefinition[] ): Accessor => { return context.scope.runTask(() => { const { root } = context; @@ -67,45 +56,7 @@ const createTextChunks = ( }); }; -interface CreateTextRangeOptions { - readonly fallbackValue?: FallbackValue; -} - -// prettier-ignore -type ComputedTextRange< - Role extends TextRole, - Definition extends TextElementDefinition | null, - FallbackValue extends string | null -> = Accessor< - Definition extends null - ? FallbackValue extends null - ? TextRange | null - : TextRange - : TextRange ->; - -// prettier-ignore -type FallbackTextRange< - Role extends TextRole, - FallbackValue extends string | null -> = - FallbackValue extends null - ? TextRange | null - : TextRange; - -const createFallbackTextRange = ( - context: EvaluationContext, - role: Role, - fallbackValue: FallbackValue -): FallbackTextRange => { - if (fallbackValue == null) { - return null as FallbackTextRange; - } - - const staticChunk = new TextChunk(context.root, 'static', fallbackValue); - - return new TextRange(role, [staticChunk]); -}; +type ComputedFormTextRange = Accessor>; /** * Creates a text range (e.g. label or hint) from the provided definition, @@ -116,40 +67,16 @@ const createFallbackTextRange = | null, - FallbackValue extends string | null = null, ->( +export const createTextRange = ( context: EvaluationContext, role: Role, - definition: Definition, - options?: CreateTextRangeOptions -): ComputedTextRange => { + definition: TextRangeDefinition +): ComputedFormTextRange => { return context.scope.runTask(() => { - if (definition == null) { - const textRange = createFallbackTextRange( - context, - role, - options?.fallbackValue ?? (null as FallbackValue) - ); - const getTextRange = () => textRange; - - return getTextRange as ComputedTextRange; - } - - const { children, referenceExpression } = definition; - - let getTextChunks: Accessor; - - if (referenceExpression == null) { - getTextChunks = createTextChunks(context, children); - } else { - getTextChunks = createTextChunks(context, [referenceExpression]); - } + const getTextChunks = createTextChunks(context, definition.chunks); return createMemo(() => { - return new TextRange(role, getTextChunks()); + return new TextRange('form', role, getTextChunks()); }); }); }; diff --git a/packages/xforms-engine/src/lib/reactivity/validation/createAggregatedViolations.ts b/packages/xforms-engine/src/lib/reactivity/validation/createAggregatedViolations.ts new file mode 100644 index 00000000..b444defb --- /dev/null +++ b/packages/xforms-engine/src/lib/reactivity/validation/createAggregatedViolations.ts @@ -0,0 +1,68 @@ +import { createMemo } from 'solid-js'; +import type { OpaqueReactiveObjectFactory } from '../../../client/OpaqueReactiveObjectFactory.ts'; +import type { + AncestorNodeValidationState, + DescendantNodeViolationReference, +} from '../../../client/validation.ts'; +import type { AnyParentNode, AnyValueNode } from '../../../instance/hierarchy.ts'; +import { createSharedNodeState } from '../node-state/createSharedNodeState.ts'; + +const violationReference = (node: AnyValueNode): DescendantNodeViolationReference | null => { + const violation = node.getViolation(); + + if (violation == null) { + return null; + } + + const { nodeId } = node; + + return { + nodeId, + get reference() { + return node.currentState.reference; + }, + violation, + }; +}; + +const collectViolationReferences = ( + context: AnyParentNode +): readonly DescendantNodeViolationReference[] => { + return context.getChildren().flatMap((child) => { + switch (child.nodeType) { + case 'string': + case 'select': { + const reference = violationReference(child); + + if (reference == null) { + return []; + } + + return [reference]; + } + + default: + return child.validationState.violations; + } + }); +}; + +interface AggregatedViolationsOptions { + readonly clientStateFactory: OpaqueReactiveObjectFactory; +} + +export const createAggregatedViolations = ( + context: AnyParentNode, + options: AggregatedViolationsOptions +): AncestorNodeValidationState => { + const { scope } = context; + + return scope.runTask(() => { + const violations = createMemo(() => { + return collectViolationReferences(context); + }); + const spec = { violations }; + + return createSharedNodeState(scope, spec, options).currentState; + }); +}; diff --git a/packages/xforms-engine/src/lib/reactivity/validation/createValidation.ts b/packages/xforms-engine/src/lib/reactivity/validation/createValidation.ts new file mode 100644 index 00000000..7be87a06 --- /dev/null +++ b/packages/xforms-engine/src/lib/reactivity/validation/createValidation.ts @@ -0,0 +1,196 @@ +import type { Accessor } from 'solid-js'; +import { createMemo } from 'solid-js'; +import type { OpaqueReactiveObjectFactory } from '../../../client/OpaqueReactiveObjectFactory.ts'; +import type { + TextRange as ClientTextRange, + ValidationTextRole, +} from '../../../client/TextRange.ts'; +import { VALIDATION_TEXT } from '../../../client/constants.ts'; +import type { + AnyViolation, + ConditionSatisfied, + ConditionValidation, + ConditionViolation, + ValidationCondition, +} from '../../../client/validation.ts'; +import type { ValidationContext } from '../../../instance/internal-api/ValidationContext.ts'; +import { TextChunk } from '../../../instance/text/TextChunk.ts'; +import { TextRange } from '../../../instance/text/TextRange.ts'; +import type { MessageDefinition } from '../../../parse/text/MessageDefinition.ts'; +import { createComputedExpression } from '../createComputedExpression.ts'; +import type { + SharedNodeState, + SharedNodeStateOptions, +} from '../node-state/createSharedNodeState.ts'; +import { createSharedNodeState } from '../node-state/createSharedNodeState.ts'; +import type { ReactiveScope } from '../scope.ts'; +import { createTextRange } from '../text/createTextRange.ts'; + +type EngineViolationMessage = ClientTextRange; + +const engineViolationMessage = ( + context: ValidationContext, + role: Role +): Accessor> => { + const messageText = VALIDATION_TEXT[role]; + const chunk = new TextChunk(context.root, 'static', messageText); + const message = new TextRange('engine', role, [chunk]); + + return () => message; +}; + +const createViolationMessage = ( + context: ValidationContext, + role: Role, + definition: MessageDefinition | null +) => { + if (definition == null) { + return engineViolationMessage(context, role); + } + + return createTextRange(context, role, definition); +}; + +// prettier-ignore +type ComputedConditionValidation< + Condition extends ValidationCondition +> = Accessor>; + +const constraintValid = (): ConditionSatisfied<'constraint'> => { + return { + condition: 'constraint', + valid: true, + message: null, + }; +}; + +const createConstraintValidation = ( + context: ValidationContext +): ComputedConditionValidation<'constraint'> => { + return context.scope.runTask(() => { + const { constraint, constraintMsg } = context.definition.bind; + + if (constraint == null) { + return constraintValid; + } + + const isValid = createComputedExpression(context, constraint, { + arbitraryDependencies: [context], + }); + + const message = createViolationMessage(context, 'constraintMsg', constraintMsg); + + return createMemo(() => { + if (!context.isRelevant() || context.isBlank() || isValid()) { + return constraintValid(); + } + + return { + condition: 'constraint', + valid: false, + message: message(), + } as const; + }); + }); +}; + +const requiredValid = (): ConditionSatisfied<'required'> => { + return { + condition: 'required', + valid: true, + message: null, + }; +}; + +const createRequiredValidation = ( + context: ValidationContext +): ComputedConditionValidation<'required'> => { + return context.scope.runTask(() => { + const { required, requiredMsg } = context.definition.bind; + + if (required.isDefaultExpression) { + return requiredValid; + } + + const isValid = () => { + if (context.isRequired()) { + return !context.isBlank(); + } + + return true; + }; + + const message = createViolationMessage(context, 'requiredMsg', requiredMsg); + + return createMemo(() => { + if (!context.isRelevant() || isValid()) { + return requiredValid(); + } + + return { + condition: 'required', + valid: false, + message: message(), + } as const; + }); + }); +}; + +type OptionalViolation = + Accessor | null>; + +const createComputedViolation = ( + scope: ReactiveScope, + validateCondition: ComputedConditionValidation +): OptionalViolation => { + return scope.runTask(() => { + return createMemo(() => { + const validation = validateCondition(); + + if (validation.valid) { + return null; + } + + return validation; + }); + }); +}; + +type ComputedViolation = Accessor; + +interface ValidationStateSpec { + readonly constraint: ComputedConditionValidation<'constraint'>; + readonly required: ComputedConditionValidation<'required'>; + readonly violation: ComputedViolation; +} + +export type SharedValidationState = SharedNodeState; + +interface ValidationStateOptions + extends SharedNodeStateOptions {} + +export const createValidationState = ( + context: ValidationContext, + options: ValidationStateOptions +): SharedValidationState => { + const { scope } = context; + + return scope.runTask(() => { + const constraint = createConstraintValidation(context); + const constraintViolation = createComputedViolation(scope, constraint); + const required = createRequiredValidation(context); + const requiredViolation = createComputedViolation(scope, required); + + const violation = createMemo(() => { + return constraintViolation() ?? requiredViolation(); + }); + + const spec: ValidationStateSpec = { + constraint, + required, + violation, + }; + + return createSharedNodeState(scope, spec, options); + }); +}; diff --git a/packages/xforms-engine/src/model/BindDefinition.ts b/packages/xforms-engine/src/model/BindDefinition.ts index 0bd0c834..167a4a20 100644 --- a/packages/xforms-engine/src/model/BindDefinition.ts +++ b/packages/xforms-engine/src/model/BindDefinition.ts @@ -3,6 +3,7 @@ import { bindDataType } from '../XFormDataType.ts'; import type { XFormDefinition } from '../XFormDefinition.ts'; import { DependencyContext } from '../expression/DependencyContext.ts'; import type { DependentExpression } from '../expression/DependentExpression.ts'; +import { MessageDefinition } from '../parse/text/MessageDefinition.ts'; import { BindComputation } from './BindComputation.ts'; import type { BindElement } from './BindElement.ts'; import type { ModelDefinition } from './ModelDefinition.ts'; @@ -25,15 +26,16 @@ export class BindDefinition extends DependencyContext { /** * Diverges from {@link https://github.com/getodk/javarosa/blob/059321160e6f8dbb3e81d9add61d68dd35b13cc8/dag.md | JavaRosa's}, which excludes `constraint` expressions. We compute `constraint` dependencies like the other computation expressions, but explicitly ignore self-references (this is currently handled by {@link BindComputation}, via its {@link DependentExpression} parent class). */ - readonly constraint: BindComputation<'constraint'> & DependentExpression<'boolean'>; + readonly constraint: BindComputation<'constraint'>; + + readonly constraintMsg: MessageDefinition<'constraintMsg'> | null; + readonly requiredMsg: MessageDefinition<'requiredMsg'> | null; // TODO: it is unclear whether this will need to be supported. // https://github.com/getodk/collect/issues/3758 mentions deprecation. readonly saveIncomplete: BindComputation<'saveIncomplete'>; - // TODO: these are deferred just to put off sharing namespace stuff - // readonly requiredMsg: string | null; - // readonly constraintMsg: string | null; + // TODO: these are deferred until prioritized // readonly preload: string | null; // readonly preloadParams: string | null; // readonly 'max-pixels': string | null; @@ -88,9 +90,9 @@ export class BindDefinition extends DependencyContext { this.required = BindComputation.forExpression(this, 'required'); this.constraint = BindComputation.forExpression(this, 'constraint'); this.saveIncomplete = BindComputation.forExpression(this, 'saveIncomplete'); + this.constraintMsg = MessageDefinition.from(this, 'constraintMsg'); + this.requiredMsg = MessageDefinition.from(this, 'requiredMsg'); - // this.requiredMsg = BindComputation.forExpression(this, 'requiredMsg'); - // this.constraintMsg = BindComputation.forExpression(this, 'constraintMsg'); // this.preload = BindComputation.forExpression(this, 'preload'); // this.preloadParams = BindComputation.forExpression(this, 'preloadParams'); // this['max-pixels'] = BindComputation.forExpression(this, 'max-pixels'); diff --git a/packages/xforms-engine/src/model/BindElement.ts b/packages/xforms-engine/src/model/BindElement.ts index cca9f56c..56f8ee8c 100644 --- a/packages/xforms-engine/src/model/BindElement.ts +++ b/packages/xforms-engine/src/model/BindElement.ts @@ -5,4 +5,5 @@ export interface BindElement { getAttribute(name: 'nodeset'): BindNodeset; getAttribute(name: string): string | null; + getAttributeNS(namespaceURI: string | null, localName: string): string | null; } diff --git a/packages/xforms-engine/src/model/ModelBindMap.ts b/packages/xforms-engine/src/model/ModelBindMap.ts index 919cf858..cb32c2a3 100644 --- a/packages/xforms-engine/src/model/ModelBindMap.ts +++ b/packages/xforms-engine/src/model/ModelBindMap.ts @@ -17,6 +17,10 @@ class ArtificialBindElement implements BindElement { return null; } + + getAttributeNS() { + return null; + } } type TopologicalSortIndex = number; diff --git a/packages/xforms-engine/src/parse/TODO.md b/packages/xforms-engine/src/parse/TODO.md new file mode 100644 index 00000000..b5d303ed --- /dev/null +++ b/packages/xforms-engine/src/parse/TODO.md @@ -0,0 +1,3 @@ +# @getodk/xforms-engine: parse + +Presence of this file is intended to be temporary! It is here to signal the intent to make a clearer distinction between the parsing and runtime aspects of the engine implementation. For entirely pragmatic purposes, newly introduced will be added here to start. We can decide in review if we want to move the rest into this directory right away. (For existing/untouched code, this would just mean moving the files and updating imports to reflect that. Small effort, lots of potential diff noise.) diff --git a/packages/xforms-engine/src/parse/text/HintDefinition.ts b/packages/xforms-engine/src/parse/text/HintDefinition.ts new file mode 100644 index 00000000..f63b589f --- /dev/null +++ b/packages/xforms-engine/src/parse/text/HintDefinition.ts @@ -0,0 +1,25 @@ +import type { LocalNamedElement } from '@getodk/common/types/dom.ts'; +import type { XFormDefinition } from '../../XFormDefinition.ts'; +import type { AnyControlDefinition } from '../../body/control/ControlDefinition.ts'; +import { getHintElement } from '../../lib/dom/query.ts'; +import { TextElementDefinition } from './abstract/TextElementDefinition.ts'; + +interface HintElement extends LocalNamedElement<'hint'> {} + +export class HintDefinition extends TextElementDefinition<'hint'> { + static forElement(form: XFormDefinition, owner: AnyControlDefinition): HintDefinition | null { + const hintElement = getHintElement(owner.element); + + if (hintElement == null) { + return null; + } + + return new this(form, owner, hintElement); + } + + readonly role = 'hint'; + + private constructor(form: XFormDefinition, owner: AnyControlDefinition, element: HintElement) { + super(form, owner, element); + } +} diff --git a/packages/xforms-engine/src/parse/text/ItemLabelDefinition.ts b/packages/xforms-engine/src/parse/text/ItemLabelDefinition.ts new file mode 100644 index 00000000..f706b88d --- /dev/null +++ b/packages/xforms-engine/src/parse/text/ItemLabelDefinition.ts @@ -0,0 +1,28 @@ +import type { LocalNamedElement } from '@getodk/common/types/dom.ts'; +import type { XFormDefinition } from '../../XFormDefinition.ts'; +import type { ItemDefinition } from '../../body/control/select/ItemDefinition.ts'; +import type { ItemsetDefinition } from '../../body/control/select/ItemsetDefinition.ts'; +import { getLabelElement } from '../../lib/dom/query.ts'; +import { TextElementDefinition } from './abstract/TextElementDefinition.ts'; + +export type ItemLabelOwner = ItemDefinition | ItemsetDefinition; + +interface LabelElement extends LocalNamedElement<'label'> {} + +export class ItemLabelDefinition extends TextElementDefinition<'item-label'> { + static from(form: XFormDefinition, owner: ItemLabelOwner): ItemLabelDefinition | null { + const labelElement = getLabelElement(owner.element); + + if (labelElement == null) { + return null; + } + + return new this(form, owner, labelElement); + } + + readonly role = 'item-label'; + + private constructor(form: XFormDefinition, owner: ItemLabelOwner, element: LabelElement) { + super(form, owner, element); + } +} diff --git a/packages/xforms-engine/src/parse/text/LabelDefinition.ts b/packages/xforms-engine/src/parse/text/LabelDefinition.ts new file mode 100644 index 00000000..838c97e7 --- /dev/null +++ b/packages/xforms-engine/src/parse/text/LabelDefinition.ts @@ -0,0 +1,61 @@ +import type { LocalNamedElement } from '@getodk/common/types/dom.ts'; +import type { XFormDefinition } from '../../XFormDefinition.ts'; +import type { AnyGroupElementDefinition } from '../../body/BodyDefinition.ts'; +import type { RepeatElementDefinition } from '../../body/RepeatElementDefinition.ts'; +import type { AnyControlDefinition } from '../../body/control/ControlDefinition.ts'; +import type { BaseGroupDefinition } from '../../body/group/BaseGroupDefinition.ts'; +import { getLabelElement, getRepeatGroupLabelElement } from '../../lib/dom/query.ts'; +import { TextElementDefinition } from './abstract/TextElementDefinition.ts'; + +// prettier-ignore +export type LabelOwner = + | AnyControlDefinition + | AnyGroupElementDefinition + | RepeatElementDefinition; + +interface LabelElement extends LocalNamedElement<'label'> {} + +export class LabelDefinition extends TextElementDefinition<'label'> { + static forControl(form: XFormDefinition, control: AnyControlDefinition): LabelDefinition | null { + const labelElement = getLabelElement(control.element); + + if (labelElement == null) { + return null; + } + + return new this(form, control, labelElement); + } + + static forRepeatGroup( + form: XFormDefinition, + repeat: RepeatElementDefinition + ): LabelDefinition | null { + const repeatGroupLabel = getRepeatGroupLabelElement(repeat.element); + + if (repeatGroupLabel == null) { + return null; + } + + return new this(form, repeat, repeatGroupLabel); + } + + static forGroup( + form: XFormDefinition, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + group: BaseGroupDefinition + ): LabelDefinition | null { + const labelElement = getLabelElement(group.element); + + if (labelElement == null) { + return null; + } + + return new this(form, group, labelElement); + } + + readonly role = 'label'; + + private constructor(form: XFormDefinition, owner: LabelOwner, element: LabelElement) { + super(form, owner, element); + } +} diff --git a/packages/xforms-engine/src/parse/text/MessageDefinition.ts b/packages/xforms-engine/src/parse/text/MessageDefinition.ts new file mode 100644 index 00000000..a4937da5 --- /dev/null +++ b/packages/xforms-engine/src/parse/text/MessageDefinition.ts @@ -0,0 +1,49 @@ +import { JAVAROSA_NAMESPACE_URI } from '@getodk/common/constants/xmlns.ts'; +import type { BindDefinition } from '../../model/BindDefinition.ts'; +import { StaticTextChunkDefinition } from './StaticTextChunkDefinition.ts'; +import { TranslationChunkDefinition } from './TranslationChunkDefinition.ts'; +import type { TextBindAttributeLocalName, TextSourceNode } from './abstract/TextRangeDefinition.ts'; +import { TextRangeDefinition } from './abstract/TextRangeDefinition.ts'; + +export type MessageSourceNode = TextSourceNode; + +// prettier-ignore +type MessageChunk = + | StaticTextChunkDefinition + | TranslationChunkDefinition; + +export class MessageDefinition< + Type extends TextBindAttributeLocalName, +> extends TextRangeDefinition { + static from( + bind: BindDefinition, + type: Type + ): MessageDefinition | null { + const message = bind.bindElement.getAttributeNS(JAVAROSA_NAMESPACE_URI, type); + + if (message == null) { + return null; + } + + return new this(bind, type, message); + } + + readonly chunks: readonly [MessageChunk]; + + private constructor( + bind: BindDefinition, + readonly role: Type, + message: string + ) { + super(bind.form, bind, null); + + const chunk: MessageChunk = + TranslationChunkDefinition.from(this, message) ?? + StaticTextChunkDefinition.from(this, message); + + this.chunks = [chunk]; + } +} + +// prettier-ignore +export type AnyMessageDefinition = MessageDefinition; diff --git a/packages/xforms-engine/src/parse/text/OutputChunkDefinition.ts b/packages/xforms-engine/src/parse/text/OutputChunkDefinition.ts new file mode 100644 index 00000000..d9ec64a8 --- /dev/null +++ b/packages/xforms-engine/src/parse/text/OutputChunkDefinition.ts @@ -0,0 +1,25 @@ +import type { KnownAttributeLocalNamedElement } from '@getodk/common/types/dom.ts'; +import { TextChunkDefinition } from './abstract/TextChunkDefinition.ts'; +import type { AnyTextRangeDefinition } from './abstract/TextRangeDefinition.ts'; + +interface OutputElement extends KnownAttributeLocalNamedElement<'output', 'value'> {} + +const isOutputElement = (element: Element): element is OutputElement => { + return element.localName === 'output' && element.hasAttribute('value'); +}; + +export class OutputChunkDefinition extends TextChunkDefinition<'output'> { + static from(context: AnyTextRangeDefinition, element: Element): OutputChunkDefinition | null { + if (isOutputElement(element)) { + return new this(context, element); + } + + return null; + } + + readonly source = 'output'; + + private constructor(context: AnyTextRangeDefinition, element: OutputElement) { + super(context, element.getAttribute('value')); + } +} diff --git a/packages/xforms-engine/src/parse/text/ReferenceChunkDefinition.ts b/packages/xforms-engine/src/parse/text/ReferenceChunkDefinition.ts new file mode 100644 index 00000000..e2f2e130 --- /dev/null +++ b/packages/xforms-engine/src/parse/text/ReferenceChunkDefinition.ts @@ -0,0 +1,14 @@ +import { TextChunkDefinition } from './abstract/TextChunkDefinition.ts'; +import type { AnyTextRangeDefinition } from './abstract/TextRangeDefinition.ts'; + +export class ReferenceChunkDefinition extends TextChunkDefinition<'reference'> { + static from(context: AnyTextRangeDefinition, refExpression: string): ReferenceChunkDefinition { + return new this(context, refExpression); + } + + readonly source = 'reference'; + + private constructor(context: AnyTextRangeDefinition, refExpression: string) { + super(context, refExpression); + } +} diff --git a/packages/xforms-engine/src/parse/text/StaticTextChunkDefinition.ts b/packages/xforms-engine/src/parse/text/StaticTextChunkDefinition.ts new file mode 100644 index 00000000..3a801b32 --- /dev/null +++ b/packages/xforms-engine/src/parse/text/StaticTextChunkDefinition.ts @@ -0,0 +1,19 @@ +import { TextChunkDefinition } from './abstract/TextChunkDefinition.ts'; +import type { AnyTextRangeDefinition } from './abstract/TextRangeDefinition.ts'; + +export type StaticTextChunkSourceNode = Attr | Text; + +export class StaticTextChunkDefinition extends TextChunkDefinition<'static'> { + static from(context: AnyTextRangeDefinition, stringValue: string): StaticTextChunkDefinition { + return new this(context, stringValue); + } + + readonly source = 'static'; + + private constructor( + context: AnyTextRangeDefinition, + override readonly stringValue: string + ) { + super(context, 'null'); + } +} diff --git a/packages/xforms-engine/src/parse/text/TranslationChunkDefinition.ts b/packages/xforms-engine/src/parse/text/TranslationChunkDefinition.ts new file mode 100644 index 00000000..3fdbf8dc --- /dev/null +++ b/packages/xforms-engine/src/parse/text/TranslationChunkDefinition.ts @@ -0,0 +1,29 @@ +import { isItextFunctionCalled } from '../../lib/xpath/analysis.ts'; +import { TextChunkDefinition } from './abstract/TextChunkDefinition.ts'; +import type { AnyTextRangeDefinition } from './abstract/TextRangeDefinition.ts'; + +type TranslationExpression = `jr:itext(${string})`; + +const isTranslationExpression = (value: string): value is TranslationExpression => { + try { + return isItextFunctionCalled(value); + } catch { + return false; + } +}; + +export class TranslationChunkDefinition extends TextChunkDefinition<'translation'> { + static from(context: AnyTextRangeDefinition, maybeExpression: string) { + if (isTranslationExpression(maybeExpression)) { + return new this(context, maybeExpression); + } + + return null; + } + + readonly source = 'translation'; + + private constructor(context: AnyTextRangeDefinition, expression: TranslationExpression) { + super(context, expression, { isTranslated: true }); + } +} diff --git a/packages/xforms-engine/src/parse/text/abstract/TextChunkDefinition.ts b/packages/xforms-engine/src/parse/text/abstract/TextChunkDefinition.ts new file mode 100644 index 00000000..9854c097 --- /dev/null +++ b/packages/xforms-engine/src/parse/text/abstract/TextChunkDefinition.ts @@ -0,0 +1,38 @@ +import type { TextChunkSource } from '../../../client/TextRange.ts'; +import { DependentExpression } from '../../../expression/DependentExpression.ts'; +import type { OutputChunkDefinition } from '../OutputChunkDefinition.ts'; +import type { ReferenceChunkDefinition } from '../ReferenceChunkDefinition.ts'; +import type { StaticTextChunkDefinition } from '../StaticTextChunkDefinition.ts'; +import type { TranslationChunkDefinition } from '../TranslationChunkDefinition.ts'; +import type { AnyTextRangeDefinition } from './TextRangeDefinition.ts'; + +interface TextChunkDefinitionOptions { + readonly isTranslated?: true; +} + +export abstract class TextChunkDefinition< + Source extends TextChunkSource, +> extends DependentExpression<'string'> { + abstract readonly source: Source; + readonly stringValue?: string; + + constructor( + context: AnyTextRangeDefinition, + expression: string, + options: TextChunkDefinitionOptions = {} + ) { + super(context, 'string', expression, { + semanticDependencies: { + translations: options.isTranslated, + }, + ignoreContextReference: true, + }); + } +} + +// prettier-ignore +export type AnyTextChunkDefinition = + | OutputChunkDefinition + | ReferenceChunkDefinition + | StaticTextChunkDefinition + | TranslationChunkDefinition; diff --git a/packages/xforms-engine/src/parse/text/abstract/TextElementDefinition.ts b/packages/xforms-engine/src/parse/text/abstract/TextElementDefinition.ts new file mode 100644 index 00000000..0436e5c1 --- /dev/null +++ b/packages/xforms-engine/src/parse/text/abstract/TextElementDefinition.ts @@ -0,0 +1,66 @@ +import { isElementNode, isTextNode } from '@getodk/common/lib/dom/predicates.ts'; +import type { XFormDefinition } from '../../../XFormDefinition.ts'; +import type { ElementTextRole } from '../../../client/TextRange.ts'; +import type { HintDefinition } from '../HintDefinition.ts'; +import type { ItemLabelOwner } from '../ItemLabelDefinition.ts'; +import type { LabelDefinition, LabelOwner } from '../LabelDefinition.ts'; +import { OutputChunkDefinition } from '../OutputChunkDefinition.ts'; +import { ReferenceChunkDefinition } from '../ReferenceChunkDefinition.ts'; +import { StaticTextChunkDefinition } from '../StaticTextChunkDefinition.ts'; +import { TranslationChunkDefinition } from '../TranslationChunkDefinition.ts'; +import type { TextSourceNode } from './TextRangeDefinition.ts'; +import { TextRangeDefinition } from './TextRangeDefinition.ts'; + +// prettier-ignore +type RefAttributeChunk = + | ReferenceChunkDefinition + | TranslationChunkDefinition; + +// prettier-ignore +type TextElementChildChunk = + | OutputChunkDefinition + | StaticTextChunkDefinition; + +// prettier-ignore +type TextElementChunks = + | readonly [RefAttributeChunk] + | readonly TextElementChildChunk[]; + +type TextElementOwner = ItemLabelOwner | LabelOwner; + +export abstract class TextElementDefinition< + Role extends ElementTextRole, +> extends TextRangeDefinition { + readonly chunks: TextElementChunks; + + constructor(form: XFormDefinition, owner: TextElementOwner, sourceNode: TextSourceNode) { + super(form, owner, sourceNode); + + const context = this as AnyTextElementDefinition; + const refExpression = sourceNode.getAttribute('ref'); + + if (refExpression == null) { + this.chunks = Array.from(sourceNode.childNodes).flatMap((childNode) => { + if (isElementNode(childNode)) { + return OutputChunkDefinition.from(context, childNode) ?? []; + } + + if (isTextNode(childNode)) { + return StaticTextChunkDefinition.from(context, childNode.data); + } + + return []; + }); + } else { + const refChunk = + TranslationChunkDefinition.from(context, refExpression) ?? + ReferenceChunkDefinition.from(context, refExpression); + this.chunks = [refChunk]; + } + } +} + +// prettier-ignore +export type AnyTextElementDefinition = + | HintDefinition + | LabelDefinition; diff --git a/packages/xforms-engine/src/parse/text/abstract/TextRangeDefinition.ts b/packages/xforms-engine/src/parse/text/abstract/TextRangeDefinition.ts new file mode 100644 index 00000000..8ddd6dea --- /dev/null +++ b/packages/xforms-engine/src/parse/text/abstract/TextRangeDefinition.ts @@ -0,0 +1,70 @@ +import type { LocalNamedElement } from '@getodk/common/types/dom.ts'; +import type { XFormDefinition } from '../../../XFormDefinition.ts'; +import type { TextRole } from '../../../client/TextRange.ts'; +import { DependencyContext } from '../../../expression/DependencyContext.ts'; +import type { AnyDependentExpression } from '../../../expression/DependentExpression.ts'; +import type { AnyMessageDefinition } from '../MessageDefinition.ts'; +import type { AnyTextChunkDefinition } from './TextChunkDefinition.ts'; +import type { AnyTextElementDefinition } from './TextElementDefinition.ts'; + +export type TextBindAttributeLocalName = 'constraintMsg' | 'requiredMsg'; +export type TextBodyElementLocalName = 'hint' | 'label'; + +interface TextSourceNodes { + readonly constraintMsg: null; + readonly hint: LocalNamedElement<'hint'>; + readonly label: LocalNamedElement<'label'>; + readonly 'item-label': LocalNamedElement<'label'>; + readonly requiredMsg: null; +} + +export type TextSourceNode = TextSourceNodes[Type]; + +export abstract class TextRangeDefinition extends DependencyContext { + abstract readonly role: Role; + readonly parentReference: string | null; + readonly reference: string | null; + + abstract readonly chunks: readonly AnyTextChunkDefinition[]; + + override get isTranslated(): boolean { + return ( + this.ownerContext.isTranslated || this.chunks.some((chunk) => chunk.source === 'translation') + ); + } + + override set isTranslated(value: true) { + if (this.ownerContext != null) { + this.ownerContext.isTranslated = value; + } + + super.isTranslated = value; + } + + protected constructor( + readonly form: XFormDefinition, + readonly ownerContext: DependencyContext, + readonly sourceNode: TextSourceNode + ) { + super(); + + this.reference = ownerContext.reference; + this.parentReference = ownerContext.parentReference; + } + + override registerDependentExpression(expression: AnyDependentExpression): void { + this.ownerContext.registerDependentExpression(expression); + super.registerDependentExpression(expression); + } + + toJSON(): object { + const { form, ownerContext, ...rest } = this; + + return rest; + } +} + +// prettier-ignore +export type AnyTextRangeDefinition = + | AnyMessageDefinition + | AnyTextElementDefinition; diff --git a/packages/xforms-engine/test/body/BodyDefinition.test.ts b/packages/xforms-engine/test/body/BodyDefinition.test.ts index 19da6a32..cbb17f0a 100644 --- a/packages/xforms-engine/test/body/BodyDefinition.test.ts +++ b/packages/xforms-engine/test/body/BodyDefinition.test.ts @@ -201,9 +201,7 @@ describe('BodyDefinition', () => { type: 'input', reference: '/root/input-label-hint', label: { - category: 'support', - type: 'label', - children: [{ expression: '"Label text"' }], + role: 'label', }, }); }); @@ -216,9 +214,7 @@ describe('BodyDefinition', () => { type: 'input', reference: '/root/input-label-hint', hint: { - category: 'support', - type: 'hint', - children: [{ expression: '"Hint text"' }], + role: 'hint', }, }); }); @@ -254,9 +250,7 @@ describe('BodyDefinition', () => { type: 'input', reference: '/root/loggrp/lg-child-2', label: { - category: 'support', - type: 'label', - children: [{ expression: '"Logical group child 2"' }], + role: 'label', }, hint: null, }, @@ -272,9 +266,7 @@ describe('BodyDefinition', () => { type: 'logical-group', reference: '/root/loggrp-2', label: { - category: 'support', - type: 'label', - children: [{ expression: '"Logical group 2 with label"' }], + role: 'label', }, }); }); @@ -312,9 +304,7 @@ describe('BodyDefinition', () => { type: 'presentation-group', reference: null, label: { - category: 'support', - type: 'label', - children: [{ expression: '"Presentation group label"' }], + role: 'label', }, }); }); @@ -336,9 +326,7 @@ describe('BodyDefinition', () => { type: 'input', reference: '/root/presgrp/pg-b', label: { - category: 'support', - type: 'label', - children: [{ expression: '"Presentation group child b"' }], + role: 'label', }, hint: null, }, @@ -414,9 +402,7 @@ describe('BodyDefinition', () => { type: 'repeat', reference: '/root/rep1', label: { - category: 'support', - type: 'label', - children: [{ expression: '"Repeat group"' }], + role: 'label', }, }); }); @@ -437,9 +423,7 @@ describe('BodyDefinition', () => { type: 'input', reference: '/root/rep1/r1-2', label: { - category: 'support', - type: 'label', - children: [{ expression: '"Repeat 1 input 2"' }], + role: 'label', }, }, ], diff --git a/packages/xforms-engine/test/model/ModelDefinition.test.ts b/packages/xforms-engine/test/model/ModelDefinition.test.ts index 3eaeb134..fe7d88c2 100644 --- a/packages/xforms-engine/test/model/ModelDefinition.test.ts +++ b/packages/xforms-engine/test/model/ModelDefinition.test.ts @@ -103,7 +103,9 @@ describe('ModelDefinition', () => { index: 0, expected: { type: 'input', - label: { children: [expect.anything()] }, + label: { + role: 'label', + }, }, }, {