From 8568fe07c5ec48e5b27da80fd32f4091588f7f75 Mon Sep 17 00:00:00 2001 From: latin-panda <66472237+latin-panda@users.noreply.github.com> Date: Wed, 22 Jan 2025 18:43:48 -0600 Subject: [PATCH] Reusing common code between select and rank --- .../src/error/RankValueTypeError.ts | 6 +- .../src/error/SelectValueTypeError.ts | 4 +- .../xforms-engine/src/instance/RankControl.ts | 8 +- .../src/lib/codecs/BaseItemCodec.ts | 20 ++++ ...ionCodec.ts => BaseItemCollectionCodec.ts} | 8 +- .../src/lib/codecs/select/BaseSelectCodec.ts | 22 ---- .../codecs/select/SingleValueSelectCodec.ts | 8 +- .../src/lib/codecs/select/getSelectCodec.ts | 6 +- .../src/lib/reactivity/createBaseItemset.ts | 104 ++++++++++++++++ .../src/lib/reactivity/createSelectItems.ts | 111 +----------------- .../body/control/RankControlDefinition.ts | 2 +- 11 files changed, 146 insertions(+), 153 deletions(-) create mode 100644 packages/xforms-engine/src/lib/codecs/BaseItemCodec.ts rename packages/xforms-engine/src/lib/codecs/{ItemCollectionCodec.ts => BaseItemCollectionCodec.ts} (75%) delete mode 100644 packages/xforms-engine/src/lib/codecs/select/BaseSelectCodec.ts create mode 100644 packages/xforms-engine/src/lib/reactivity/createBaseItemset.ts diff --git a/packages/xforms-engine/src/error/RankValueTypeError.ts b/packages/xforms-engine/src/error/RankValueTypeError.ts index 6ba4b53f5..9c0121b00 100644 --- a/packages/xforms-engine/src/error/RankValueTypeError.ts +++ b/packages/xforms-engine/src/error/RankValueTypeError.ts @@ -1,12 +1,10 @@ import { XFormsSpecViolationError } from './XFormsSpecViolationError.ts'; import type { RankDefinition } from '../client/RankNode.ts'; -import type { UnsupportedRankValueType } from '../lib/codecs/select/BaseSelectCodec.ts'; +import type { UnsupportedBaseItemValueType } from '../lib/codecs/BaseItemCodec.ts'; import { XPathFunctionalityError, type XPathFunctionalityErrorCategory } from './XPathFunctionalityError.ts'; export class RankValueTypeError extends XFormsSpecViolationError { - constructor(definition: RankDefinition) { - // ToDo fix typing - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + constructor(definition: RankDefinition) { const { valueType } = definition; super( diff --git a/packages/xforms-engine/src/error/SelectValueTypeError.ts b/packages/xforms-engine/src/error/SelectValueTypeError.ts index 5a5c26ff7..cb34e35cf 100644 --- a/packages/xforms-engine/src/error/SelectValueTypeError.ts +++ b/packages/xforms-engine/src/error/SelectValueTypeError.ts @@ -1,5 +1,5 @@ import type { SelectDefinition } from '../client/SelectNode.ts'; -import type { UnsupportedSelectValueType } from '../lib/codecs/select/BaseSelectCodec.ts'; +import type { UnsupportedBaseItemValueType } from '../lib/codecs/BaseItemCodec.ts'; import { XFormsSpecViolationError } from './XFormsSpecViolationError.ts'; /** @@ -12,7 +12,7 @@ import { XFormsSpecViolationError } from './XFormsSpecViolationError.ts'; * @see {@link https://github.com/getodk/web-forms/issues/276} */ export class SelectValueTypeError extends XFormsSpecViolationError { - constructor(definition: SelectDefinition) { + constructor(definition: SelectDefinition) { const { valueType } = definition; super( diff --git a/packages/xforms-engine/src/instance/RankControl.ts b/packages/xforms-engine/src/instance/RankControl.ts index b466868d2..e1eca7dee 100644 --- a/packages/xforms-engine/src/instance/RankControl.ts +++ b/packages/xforms-engine/src/instance/RankControl.ts @@ -9,7 +9,7 @@ import type { } from '../client/RankNode.ts'; import type { TextRange } from '../client/TextRange.ts'; import type { XFormsXPathElement } from '../integration/xpath/adapter/XFormsXPathNode.ts'; -import { createSelectItems } from '../lib/reactivity/createSelectItems.ts'; +import { createItemset } from '../lib/reactivity/createBaseItemset.ts'; import type { CurrentState } from '../lib/reactivity/node-state/createCurrentState.ts'; import type { EngineState } from '../lib/reactivity/node-state/createEngineState.ts'; import type { SharedNodeState } from '../lib/reactivity/node-state/createSharedNodeState.ts'; @@ -25,7 +25,7 @@ import type { EvaluationContext } from './internal-api/EvaluationContext.ts'; import type { ValidationContext } from './internal-api/ValidationContext.ts'; import type { ClientReactiveSubmittableValueNode } from './internal-api/submission/ClientReactiveSubmittableValueNode.ts'; import { RankFunctionalityError, RankValueTypeError } from '../error/RankValueTypeError.ts'; -import { ItemCollectionCodec } from '../lib/codecs/ItemCollectionCodec.ts'; +import { BaseItemCollectionCodec } from '../lib/codecs/BaseItemCollectionCodec.ts'; import { sharedValueCodecs } from '../lib/codecs/getSharedValueCodec.ts'; import type { AnyNodeDefinition } from '../parse/model/NodeDefinition.ts'; @@ -74,10 +74,10 @@ export class RankControl readonly currentState: CurrentState; private constructor(parent: GeneralParentNode, definition: RankDefinition<'string'>) { - const codec = new ItemCollectionCodec(sharedValueCodecs.string); + const codec = new BaseItemCollectionCodec(sharedValueCodecs.string); super(parent, definition, codec); - const valueOptions = createSelectItems(this); // ToDo extract to reuse function + const valueOptions = createItemset(this, definition.bodyElement.itemset); const mapOptionsByValue: Accessor = this.scope.runTask(() => { return createMemo(() => { return new Map(valueOptions().map((item) => [item.value, item])); diff --git a/packages/xforms-engine/src/lib/codecs/BaseItemCodec.ts b/packages/xforms-engine/src/lib/codecs/BaseItemCodec.ts new file mode 100644 index 000000000..984bb5295 --- /dev/null +++ b/packages/xforms-engine/src/lib/codecs/BaseItemCodec.ts @@ -0,0 +1,20 @@ +import type { ValueType } from '../../client/ValueType.ts'; +import type { SharedValueCodec } from './getSharedValueCodec.ts'; +import { ValueArrayCodec } from './ValueArrayCodec.ts'; +import type { CodecDecoder, CodecEncoder } from './ValueCodec.ts'; + +export type BaseItemValueType = 'string'; + +export type UnsupportedBaseItemValueType = Exclude; + +export abstract class BaseItemCodec< + Values extends readonly string[] = readonly string[], +> extends ValueArrayCodec { + constructor( + baseCodec: SharedValueCodec<'string'>, + encodeValue: CodecEncoder, + decodeValue: CodecDecoder + ) { + super(baseCodec, encodeValue, decodeValue); + } +} diff --git a/packages/xforms-engine/src/lib/codecs/ItemCollectionCodec.ts b/packages/xforms-engine/src/lib/codecs/BaseItemCollectionCodec.ts similarity index 75% rename from packages/xforms-engine/src/lib/codecs/ItemCollectionCodec.ts rename to packages/xforms-engine/src/lib/codecs/BaseItemCollectionCodec.ts index 0ad15bc6d..f9b50d098 100644 --- a/packages/xforms-engine/src/lib/codecs/ItemCollectionCodec.ts +++ b/packages/xforms-engine/src/lib/codecs/BaseItemCollectionCodec.ts @@ -1,18 +1,18 @@ import { xmlXPathWhitespaceSeparatedList } from '@getodk/common/lib/string/whitespace.ts'; import type { SharedValueCodec } from './getSharedValueCodec.ts'; import type { CodecDecoder, CodecEncoder } from './ValueCodec.ts'; -import { BaseSelectCodec } from './select/BaseSelectCodec.ts'; +import { BaseItemCodec } from './BaseItemCodec.ts'; /** * Value codec implementation for `` and `` * values: the former are serialized as a space-separated list, but that does @@ -50,7 +50,7 @@ const encodeValueFactory = ( * 2. as an optimization, as the more general implementation performs poorly on * forms which we monitor for performance. */ -export class SingleValueSelectCodec extends BaseSelectCodec { +export class SingleValueSelectCodec extends BaseItemCodec { constructor(baseCodec: SharedValueCodec<'string'>) { const encodeValue = encodeValueFactory(baseCodec); diff --git a/packages/xforms-engine/src/lib/codecs/select/getSelectCodec.ts b/packages/xforms-engine/src/lib/codecs/select/getSelectCodec.ts index 15ea759fe..c401443f4 100644 --- a/packages/xforms-engine/src/lib/codecs/select/getSelectCodec.ts +++ b/packages/xforms-engine/src/lib/codecs/select/getSelectCodec.ts @@ -1,16 +1,16 @@ import { UnreachableError } from '@getodk/common/lib/error/UnreachableError.ts'; import type { SelectDefinition } from '../../../client/SelectNode.ts'; import { sharedValueCodecs } from '../getSharedValueCodec.ts'; -import { ItemCollectionCodec } from '../ItemCollectionCodec.ts'; +import { BaseItemCollectionCodec } from '../BaseItemCollectionCodec.ts'; import { SingleValueSelectCodec } from './SingleValueSelectCodec.ts'; -const multipleValueSelectCodec = new ItemCollectionCodec(sharedValueCodecs.string); +const multipleValueSelectCodec = new BaseItemCollectionCodec(sharedValueCodecs.string); const singleValueSelectCodec = new SingleValueSelectCodec(sharedValueCodecs.string); // prettier-ignore export type SelectCodec = - | ItemCollectionCodec + | BaseItemCollectionCodec | SingleValueSelectCodec; export const getSelectCodec = (definition: SelectDefinition<'string'>): SelectCodec => { diff --git a/packages/xforms-engine/src/lib/reactivity/createBaseItemset.ts b/packages/xforms-engine/src/lib/reactivity/createBaseItemset.ts new file mode 100644 index 000000000..124a393eb --- /dev/null +++ b/packages/xforms-engine/src/lib/reactivity/createBaseItemset.ts @@ -0,0 +1,104 @@ +import { UpsertableMap } from '@getodk/common/lib/collections/UpsertableMap.ts'; +import type { Accessor } from 'solid-js'; +import { createMemo } from 'solid-js'; +import type { ActiveLanguage } from '../../client/FormLanguage.ts'; +import type { TextRange as ClientTextRange } from '../../client/TextRange.ts'; +import type { EvaluationContext } from '../../instance/internal-api/EvaluationContext.ts'; +import type { EngineXPathNode } from '../../integration/xpath/adapter/kind.ts'; +import type { EngineXPathEvaluator } from '../../integration/xpath/EngineXPathEvaluator.ts'; +import type { ItemsetDefinition } from '../../parse/body/control/ItemsetDefinition.ts'; +import { createComputedExpression } from './createComputedExpression.ts'; +import type { ReactiveScope } from './scope.ts'; +import { createTextRange } from './text/createTextRange.ts'; +import type { RankControl } from '../../instance/RankControl.ts'; +import type { SelectControl } from '../../instance/SelectControl.ts'; +import type { TranslationContext } from '../../instance/internal-api/TranslationContext.ts'; +import { TextChunk } from '../../instance/text/TextChunk.ts'; +import { TextRange } from '../../instance/text/TextRange.ts'; + +type ItemsetControl = SelectControl | RankControl; + +class ItemsetItemEvaluationContext implements EvaluationContext { + readonly isAttached: Accessor; + readonly scope: ReactiveScope; + readonly evaluator: EngineXPathEvaluator; + readonly contextReference: Accessor; + readonly getActiveLanguage: Accessor; + + constructor(control: ItemsetControl, readonly contextNode: EngineXPathNode) { + this.isAttached = control.isAttached; + this.scope = control.scope; + this.evaluator = control.evaluator; + this.contextReference = control.contextReference; + this.getActiveLanguage = control.getActiveLanguage; + } +} + +type DerivedItemLabel = ClientTextRange<'item-label', 'form-derived'>; + +export const derivedItemLabel = (context: TranslationContext, value: string): DerivedItemLabel => { + const chunk = new TextChunk(context, 'literal', value); + + return new TextRange('form-derived', 'item-label', [chunk]); +}; + +const createItemsetItemLabel = ( + context: EvaluationContext, + definition: ItemsetDefinition, + itemValue: Accessor +): Accessor> => { + const { label } = definition; + + if (label == null) { + return createMemo(() => derivedItemLabel(context, itemValue())); + } + + return createTextRange(context, 'item-label', label); +}; + +interface ItemsetItem { + label(): ClientTextRange<'item-label'>; + value(): string; +} + +const createItemsetItems = (control: ItemsetControl, itemset: ItemsetDefinition): Accessor => { + return control.scope.runTask(() => { + const itemNodes = createComputedExpression(control, itemset.nodes, { defaultValue: [] }); + const itemsCache = new UpsertableMap(); + + return createMemo(() => { + return itemNodes().map((itemNode) => { + return itemsCache.upsert(itemNode, () => { + const context = new ItemsetItemEvaluationContext(control, itemNode); + const value = createComputedExpression(context, itemset.value, { defaultValue: '' }); + const label = createItemsetItemLabel(context, itemset, value); + + return { label, value }; + }); + }); + }); + }); +}; + +export interface SourceValueItem { + readonly value: string; + readonly label: ClientTextRange<'item-label'>; +} + +export const createItemset = ( + control: ItemsetControl, + itemset: ItemsetDefinition, +): Accessor => { + return control.scope.runTask(() => { + const itemsetItems = createItemsetItems(control, itemset); + + return createMemo(() => { + return itemsetItems().map((item) => { + return { + label: item.label(), + value: item.value(), + }; + }); + }); + }); +}; diff --git a/packages/xforms-engine/src/lib/reactivity/createSelectItems.ts b/packages/xforms-engine/src/lib/reactivity/createSelectItems.ts index 61c9a62a8..a0573bb54 100644 --- a/packages/xforms-engine/src/lib/reactivity/createSelectItems.ts +++ b/packages/xforms-engine/src/lib/reactivity/createSelectItems.ts @@ -1,29 +1,12 @@ -import { UpsertableMap } from '@getodk/common/lib/collections/UpsertableMap.ts'; import type { Accessor } from 'solid-js'; import { createMemo } from 'solid-js'; -import type { ActiveLanguage } from '../../client/FormLanguage.ts'; import type { SelectItem } from '../../client/SelectNode.ts'; import type { TextRange as ClientTextRange } from '../../client/TextRange.ts'; import type { EvaluationContext } from '../../instance/internal-api/EvaluationContext.ts'; -import type { TranslationContext } from '../../instance/internal-api/TranslationContext.ts'; import type { SelectControl } from '../../instance/SelectControl.ts'; -import { TextChunk } from '../../instance/text/TextChunk.ts'; -import { TextRange } from '../../instance/text/TextRange.ts'; -import type { EngineXPathNode } from '../../integration/xpath/adapter/kind.ts'; -import type { EngineXPathEvaluator } from '../../integration/xpath/EngineXPathEvaluator.ts'; import type { ItemDefinition } from '../../parse/body/control/ItemDefinition.ts'; -import type { ItemsetDefinition } from '../../parse/body/control/ItemsetDefinition.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: TranslationContext, value: string): DerivedItemLabel => { - const chunk = new TextChunk(context, 'literal', value); - - return new TextRange('form-derived', 'item-label', [chunk]); -}; +import type { SourceValueItem, createItemset, derivedItemLabel } from './createBaseItemset.ts'; const createSelectItemLabel = ( context: EvaluationContext, @@ -38,10 +21,7 @@ const createSelectItemLabel = ( return createTextRange(context, 'item-label', label); }; -interface SourceValueSelectItem { - readonly value: string; - readonly label: ClientTextRange<'item-label'>; -} +interface SourceValueSelectItem extends SourceValueItem { } const createTranslatedStaticSelectItems = ( select: SelectControl, @@ -64,93 +44,6 @@ const createTranslatedStaticSelectItems = ( }); }; -class ItemsetItemEvaluationContext implements EvaluationContext { - readonly isAttached: Accessor; - readonly scope: ReactiveScope; - readonly evaluator: EngineXPathEvaluator; - readonly contextReference: Accessor; - readonly getActiveLanguage: Accessor; - - constructor( - select: SelectControl, - readonly contextNode: EngineXPathNode - ) { - this.isAttached = select.isAttached; - this.scope = select.scope; - this.evaluator = select.evaluator; - this.contextReference = select.contextReference; - this.getActiveLanguage = select.getActiveLanguage; - } -} - -const createSelectItemsetItemLabel = ( - context: EvaluationContext, - definition: ItemsetDefinition, - itemValue: Accessor -): Accessor> => { - const { label } = definition; - - if (label == null) { - return createMemo(() => { - return derivedItemLabel(context, itemValue()); - }); - } - - return createTextRange(context, 'item-label', label); -}; - -interface ItemsetItem { - label(): ClientTextRange<'item-label'>; - value(): string; -} - -const createItemsetItems = ( - select: SelectControl, - itemset: ItemsetDefinition -): Accessor => { - return select.scope.runTask(() => { - const itemNodes = createComputedExpression(select, itemset.nodes, { - defaultValue: [], - }); - const itemsCache = new UpsertableMap(); - - return createMemo(() => { - return itemNodes().map((itemNode) => { - return itemsCache.upsert(itemNode, () => { - const context = new ItemsetItemEvaluationContext(select, itemNode); - const value = createComputedExpression(context, itemset.value, { - defaultValue: '', - }); - const label = createSelectItemsetItemLabel(context, itemset, value); - - return { - label, - value, - }; - }); - }); - }); - }); -}; - -const createItemset = ( - select: SelectControl, - itemset: ItemsetDefinition -): Accessor => { - return select.scope.runTask(() => { - const itemsetItems = createItemsetItems(select, itemset); - - return createMemo(() => { - return itemsetItems().map((item) => { - return { - label: item.label(), - value: item.value(), - }; - }); - }); - }); -}; - /** * Creates a reactive computation of a {@link SelectControl}'s * {@link SelectItem}s, in support of the field's `valueOptions`. diff --git a/packages/xforms-engine/src/parse/body/control/RankControlDefinition.ts b/packages/xforms-engine/src/parse/body/control/RankControlDefinition.ts index fd2eb73b4..83839ac8e 100644 --- a/packages/xforms-engine/src/parse/body/control/RankControlDefinition.ts +++ b/packages/xforms-engine/src/parse/body/control/RankControlDefinition.ts @@ -19,7 +19,7 @@ export class RankControlDefinition extends ControlDefinition { readonly type: RankType = 'rank'; readonly element: RankElement; - readonly itemset: ItemsetDefinition | null; + readonly itemset: ItemsetDefinition; constructor(form: XFormDefinition, parent: BodyElementParentContext, element: Element) { if (!RankControlDefinition.isRankElement(element)) {