diff --git a/.changeset/curvy-owls-joke.md b/.changeset/curvy-owls-joke.md new file mode 100644 index 000000000..3ec6bf3e9 --- /dev/null +++ b/.changeset/curvy-owls-joke.md @@ -0,0 +1,10 @@ +--- +"@getodk/common": patch +"@getodk/scenario": patch +"@getodk/web-forms": minor +--- + +- Partial support for `` (basic horizontal and vertical sliders) +- Partial support for `` bind/value types (string, int, decimal) +- Partial support for ` + + + \ No newline at end of file diff --git a/packages/common/src/fixtures/select/5-select-types.xml b/packages/common/src/fixtures/select/5-select-types.xml new file mode 100644 index 000000000..ec005d468 --- /dev/null +++ b/packages/common/src/fixtures/select/5-select-types.xml @@ -0,0 +1,187 @@ + + + + Select types + + + + yes + + + explicit string + implicit string + 123 + 45.67 + + + + explicit string + implicit string + 123 + 45.67 + + + + + + + + implicit string + + + + explicit string + + + + updated string + + + + + + implicit + + + + explicit + + + + updated + + + + string + + + + + + + + 123 + + + + 10 + + + + 23 + + + + 89 + + + + + + + + 45.67 + + + + 89 + + + + 10 + + + + 23.4 + + + + + + + + + + + + + + + + + + + + yes + + + + no + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/common/src/fixtures/test-web-forms/select-control.xml b/packages/common/src/fixtures/test-web-forms/select-control.xml new file mode 100644 index 000000000..0d7106d5c --- /dev/null +++ b/packages/common/src/fixtures/test-web-forms/select-control.xml @@ -0,0 +1,424 @@ + + + + SelectControl + + + + + cherry + peach + + + + + + + + + + + + + + + + + + karachi + + + + toronto + + + + lahore + + + + islamabad + + + + vancouver + + + + + + + + yes + + + + no + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + mango + + + + cherry + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/common/src/test/fixtures/xform-dsl/index.ts b/packages/common/src/test/fixtures/xform-dsl/index.ts index 9e5359c7d..f298d8986 100644 --- a/packages/common/src/test/fixtures/xform-dsl/index.ts +++ b/packages/common/src/test/fixtures/xform-dsl/index.ts @@ -172,6 +172,52 @@ export const select1Dynamic: select1Dynamic = ( ); }; +/** + * @see {@link proposed_selectDynamic} + */ +type Proposed_SelectDynamicParameters = + // eslint-disable-next-line @typescript-eslint/sort-type-constituents + | readonly [ref: string, nodesetRef: string] + | readonly [ref: string, nodesetRef: string, valueRef: string, labelRef: string]; + +/** + * @see {@link proposed_selectDynamic} + */ +type Proposed_selectDynamic = (...args: Proposed_SelectDynamicParameters) => XFormsElement; + +/** + * **PORTING NOTES** + * + * As the name implies, this is not ported from JavaRosa. It is a proposed + * addition, for parity with {@link select1Dynamic}. + */ +export const proposed_selectDynamic: Proposed_selectDynamic = ( + ...[ref, nodesetRef, valueRef, labelRef]: Proposed_SelectDynamicParameters +): XFormsElement => { + if (valueRef == null && labelRef == null) { + const value = t('value ref="value"'); + const label = t('label ref="label"'); + + const itemsetAttributes = new Map(); + + itemsetAttributes.set('nodeset', nodesetRef); + + const itemset = new TagXFormsElement('itemset', itemsetAttributes, [value, label]); + const selectAttributes = new Map(); + + selectAttributes.set('ref', ref); + + return new TagXFormsElement('select', selectAttributes, [itemset]); + } + + return t( + `select ref="${ref}"`, + t(`itemset nodeset="${nodesetRef}"`, t(`value ref="${valueRef}"`), t(`label ref="${labelRef}"`)) + ); +}; + +export { proposed_selectDynamic as selectDynamic }; + export const group = (ref: string, ...children: XFormsElement[]): XFormsElement => { return t(`group ref="${ref}"`, ...children); }; diff --git a/packages/scenario/src/answer/NoteNodeAnswer.ts b/packages/scenario/src/answer/NoteNodeAnswer.ts index b796ba55b..513e8e124 100644 --- a/packages/scenario/src/answer/NoteNodeAnswer.ts +++ b/packages/scenario/src/answer/NoteNodeAnswer.ts @@ -1,8 +1,8 @@ -import type { NoteNode } from '@getodk/xforms-engine'; +import type { AnyNoteNode } from '@getodk/xforms-engine'; import { ValueNodeAnswer } from './ValueNodeAnswer.ts'; -export class NoteNodeAnswer extends ValueNodeAnswer { +export class NoteNodeAnswer extends ValueNodeAnswer { get stringValue(): string { - return this.node.currentState.value ?? ''; + return this.node.currentState.instanceValue; } } diff --git a/packages/scenario/src/answer/RangeNodeAnswer.ts b/packages/scenario/src/answer/RangeNodeAnswer.ts new file mode 100644 index 000000000..1dc7f456d --- /dev/null +++ b/packages/scenario/src/answer/RangeNodeAnswer.ts @@ -0,0 +1,17 @@ +import type { RangeNode, RangeValue, RangeValueType } from '@getodk/xforms-engine'; +import { ValueNodeAnswer } from './ValueNodeAnswer.ts'; + +export class RangeNodeAnswer extends ValueNodeAnswer< + RangeNode +> { + readonly valueType: V; + readonly stringValue: string; + readonly value: RangeValue; + + constructor(node: RangeNode) { + super(node); + this.valueType = node.valueType; + this.stringValue = this.node.currentState.instanceValue; + this.value = this.node.currentState.value; + } +} diff --git a/packages/scenario/src/answer/SelectNodeAnswer.ts b/packages/scenario/src/answer/SelectNodeAnswer.ts index b2078970c..653459854 100644 --- a/packages/scenario/src/answer/SelectNodeAnswer.ts +++ b/packages/scenario/src/answer/SelectNodeAnswer.ts @@ -1,22 +1,23 @@ import type { JSONValue } from '@getodk/common/types/JSONValue.ts'; -import type { SelectNode } from '@getodk/xforms-engine'; +import type { SelectNode, SelectValues, ValueType } from '@getodk/xforms-engine'; import { ValueNodeAnswer } from './ValueNodeAnswer.ts'; -export class SelectNodeAnswer extends ValueNodeAnswer { - /** - * @todo There probably should be some means to get this from the engine, but - * we should be careful not to incentivize clients attempting to reproduce - * engine behavior with it. - */ - get stringValue(): string { - return this.itemValues().join(' '); - } +export class SelectNodeAnswer extends ValueNodeAnswer< + SelectNode +> { + readonly valueType: V; + readonly stringValue: string; + readonly value: SelectValues; + + constructor(node: SelectNode) { + super(node); - private itemValues(): readonly string[] { - return this.node.currentState.value.map((item) => item.value); + this.valueType = node.valueType; + this.stringValue = node.currentState.instanceValue; + this.value = node.currentState.value.slice(); } override inspectValue(): JSONValue { - return this.itemValues(); + return this.stringValue; } } diff --git a/packages/scenario/src/answer/ValueNodeAnswer.ts b/packages/scenario/src/answer/ValueNodeAnswer.ts index 43ba4c82e..f98d160d0 100644 --- a/packages/scenario/src/answer/ValueNodeAnswer.ts +++ b/packages/scenario/src/answer/ValueNodeAnswer.ts @@ -1,7 +1,19 @@ -import type { AnyLeafNode, InputNode, ModelValueNode } from '@getodk/xforms-engine'; +import type { + AnyLeafNode, + InputNode, + ModelValueNode, + RangeNode, + SelectNode, +} from '@getodk/xforms-engine'; import { ComparableAnswer } from './ComparableAnswer.ts'; -export type ValueNode = AnyLeafNode | InputNode | ModelValueNode; +// prettier-ignore +export type ValueNode = + | AnyLeafNode + | InputNode + | ModelValueNode + | RangeNode + | SelectNode; export abstract class ValueNodeAnswer extends ComparableAnswer { constructor(readonly node: Node) { diff --git a/packages/scenario/src/choice/ExpectedChoice.ts b/packages/scenario/src/choice/ExpectedChoice.ts index b2713d752..8ef6e2843 100644 --- a/packages/scenario/src/choice/ExpectedChoice.ts +++ b/packages/scenario/src/choice/ExpectedChoice.ts @@ -43,8 +43,8 @@ export interface ComparableChoiceEqualityExpectationResult { * * - `false` if both: * - {@link ExpectedChoice.label} is a string - * - The compared ("actual") {@link ComparableChoice.label} is null or has - * a different string value + * - The compared ("actual") {@link ComparableChoice.label} has a different + * string value * * - {@link LabelEqualityNotApplicable} if no label is expressed in the * calling assertion (i.e. {@link ExpectedChoice.label} is `null`) diff --git a/packages/scenario/src/jr/Scenario.ts b/packages/scenario/src/jr/Scenario.ts index 365872692..effcba85a 100644 --- a/packages/scenario/src/jr/Scenario.ts +++ b/packages/scenario/src/jr/Scenario.ts @@ -1,20 +1,24 @@ import type { XFormsElement } from '@getodk/common/test/fixtures/xform-dsl/XFormsElement.ts'; import type { AnyNode, + AnySelectNode, OpaqueReactiveObjectFactory, RepeatRangeControlledNode, RepeatRangeNode, RepeatRangeUncontrolledNode, RootNode, SelectNode, + SelectValues, SubmissionChunkedType, SubmissionOptions, SubmissionResult, + ValueType, } from '@getodk/xforms-engine'; import { constants as ENGINE_CONSTANTS } from '@getodk/xforms-engine'; import type { Accessor, Setter } from 'solid-js'; import { createMemo, createSignal, runWithOwner } from 'solid-js'; -import { afterEach, expect } from 'vitest'; +import { afterEach, assert, expect } from 'vitest'; +import { SelectNodeAnswer } from '../answer/SelectNodeAnswer.ts'; import { SelectValuesAnswer } from '../answer/SelectValuesAnswer.ts'; import type { ValueNodeAnswer } from '../answer/ValueNodeAnswer.ts'; import { answerOf } from '../client/answerOf.ts'; @@ -391,6 +395,23 @@ export class Scenario { return event.answerQuestion(new SelectValuesAnswer(selectionValues)); } + proposed_answerTypedSelect( + reference: string, + valueType: V, + values: SelectValues + ): SelectNodeAnswer { + const node = this.getInstanceNode(reference); + + assert(node.nodeType === 'select'); + assert(node.valueType === valueType); + + const selectNode = node as SelectNode; + + selectNode.selectValues(values); + + return new SelectNodeAnswer(selectNode); + } + answer(...args: AnswerParameters): ValueNodeAnswer { if (isAnswerSelectParams(args)) { return this.answerSelect(...args); @@ -879,7 +900,7 @@ export class Scenario { return label; } - private getCurrentSelectNode(options: AssertCurrentReferenceOptions): SelectNode { + private getCurrentSelectNode(options: AssertCurrentReferenceOptions): AnySelectNode { const { assertCurrentReference } = options; const event = this.getSelectedPositionalEvent(); @@ -927,7 +948,11 @@ export class Scenario { const node = this.getCurrentSelectNode(options); return node.currentState.value.map((item) => { - return item.label?.asString ?? item.value; + const option = node.getValueOption(item); + + assert(option != null); + + return option.label.asString; }); } @@ -942,7 +967,7 @@ export class Scenario { const node = this.getCurrentSelectNode(options); return node.currentState.valueOptions.map((item) => { - return item.label?.asString ?? item.value; + return item.label.asString; }); } diff --git a/packages/scenario/src/jr/event/PositionalEvent.ts b/packages/scenario/src/jr/event/PositionalEvent.ts index b25a58af6..7500e6cad 100644 --- a/packages/scenario/src/jr/event/PositionalEvent.ts +++ b/packages/scenario/src/jr/event/PositionalEvent.ts @@ -1,13 +1,14 @@ import { assertInstanceType } from '@getodk/common/lib/runtime-types/instance-predicates.ts'; import type { AnyInputNode, + AnyNoteNode, + AnyRangeNode, + AnySelectNode, AnyUnsupportedControlNode, GroupNode, - NoteNode, RepeatInstanceNode, RepeatRangeUncontrolledNode, RootNode, - SelectNode, TriggerNode, } from '@getodk/xforms-engine'; import type { Scenario } from '../Scenario.ts'; @@ -15,9 +16,10 @@ import type { Scenario } from '../Scenario.ts'; // prettier-ignore export type QuestionPositionalEventNode = // eslint-disable-next-line @typescript-eslint/sort-type-constituents - | NoteNode - | SelectNode + | AnyNoteNode + | AnySelectNode | AnyInputNode + | AnyRangeNode | TriggerNode | AnyUnsupportedControlNode; @@ -69,7 +71,7 @@ const singletons = new Map(); export abstract class PositionalEvent { static from>( this: PositionalEventConstructor, - node: PositionalEventNode + node: PositionalEventConstructorNode ): Inst { let singleton = singletons.get(node); @@ -96,3 +98,13 @@ type PositionalEventConstructor< Type extends PositionalEventType, Inst extends PositionalEvent, > = new (node: Inst['node']) => Inst; + +// prettier-ignore +type PositionalEventConstructorNode< + Type extends PositionalEventType, + Inst extends PositionalEvent +> = + PositionalEventConstructor extends + (new (node: infer T) => Inst) + ? T + : never; diff --git a/packages/scenario/src/jr/event/RangeQuestionEvent.ts b/packages/scenario/src/jr/event/RangeQuestionEvent.ts new file mode 100644 index 000000000..e1da5ba9a --- /dev/null +++ b/packages/scenario/src/jr/event/RangeQuestionEvent.ts @@ -0,0 +1,55 @@ +import type { AnyRangeNode, DecimalRangeNode, IntRangeNode } from '@getodk/xforms-engine'; +import { RangeNodeAnswer } from '../../answer/RangeNodeAnswer.ts'; +import { UntypedAnswer } from '../../answer/UntypedAnswer.ts'; +import type { ValueNodeAnswer } from '../../answer/ValueNodeAnswer.ts'; +import { QuestionEvent } from './QuestionEvent.ts'; + +export class RangeQuestionEvent extends QuestionEvent<'range'> { + getAnswer(): RangeNodeAnswer { + return new RangeNodeAnswer(this.node); + } + + private answerDefault(node: AnyRangeNode, answerValue: unknown): ValueNodeAnswer { + const { stringValue } = new UntypedAnswer(answerValue); + + node.setValue(stringValue); + + return new RangeNodeAnswer(node); + } + + private answerNumericQuestionNode( + node: DecimalRangeNode | IntRangeNode, + answerValue: unknown + ): ValueNodeAnswer { + if (answerValue === null) { + node.setValue(answerValue); + + return new RangeNodeAnswer(node); + } + + switch (typeof answerValue) { + case 'bigint': + case 'number': + case 'string': + node.setValue(answerValue); + + return new RangeNodeAnswer(node); + + default: + return this.answerDefault(node, answerValue); + } + } + + answerQuestion(answerValue: unknown): ValueNodeAnswer { + const { node } = this; + + switch (node.valueType) { + case 'int': + case 'decimal': + return this.answerNumericQuestionNode(node, answerValue); + + default: + return this.answerDefault(node, answerValue); + } + } +} diff --git a/packages/scenario/src/jr/event/SelectQuestionEvent.ts b/packages/scenario/src/jr/event/SelectQuestionEvent.ts index ee24ab4cd..4a19f5f50 100644 --- a/packages/scenario/src/jr/event/SelectQuestionEvent.ts +++ b/packages/scenario/src/jr/event/SelectQuestionEvent.ts @@ -1,18 +1,32 @@ +import { UnreachableError } from '@getodk/common/lib/error/UnreachableError.ts'; import { xmlXPathWhitespaceSeparatedList } from '@getodk/common/lib/string/whitespace.ts'; -import type { SelectNode } from '@getodk/xforms-engine'; +import type { + AnySelectNode, + RootNode, + SelectNode, + SelectValues, + ValueType, +} from '@getodk/xforms-engine'; +import { assert } from 'vitest'; 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'; +interface StringValueSelectWriteMethods { + selectValue(value: string): RootNode; + selectValues(values: readonly string[]): RootNode; +} + +type StringValueSelectNode = Extract; + export class SelectQuestionEvent extends QuestionEvent<'select'> { getAnswer(): SelectNodeAnswer { return new SelectNodeAnswer(this.node); } - getChoice(choiceIndex: number): SelectChoice { + getChoice(choiceIndex: number): SelectChoice { const items = this.node.currentState.valueOptions; const item = items[choiceIndex]; @@ -23,45 +37,98 @@ export class SelectQuestionEvent extends QuestionEvent<'select'> { return new SelectChoice(item); } + private getOptionValues( + node: SelectNode, + stringValues: readonly string[] + ): SelectValues { + const optionsByStringValue = new Map( + node.currentState.valueOptions.map((item) => { + return [item.asString, item]; + }) + ); + + return stringValues.map((stringValue) => { + const option = optionsByStringValue.get(stringValue); + + assert(option); + + return option.value; + }); + } + + private answerTypedQuestion( + node: SelectNode, + stringValues: readonly string[] + ): SelectNodeAnswer { + const values = this.getOptionValues(node, stringValues); + + node.selectValues(values); + + return new SelectNodeAnswer(node); + } + /** - * @todo Per @sadiqkhoja, this is another good example of where at least one - * kind of "select multiple" API would be much more sensible. - * - * @todo It's also pretty likely we might want an escape hatch for setting - * encoded values directly from the client. But proceed with caution: if we - * did this, we'd need to apply all of the same parsing, sanitization, - * validation, etc logic (likely per node and data type) at that client API - * boundary as we do internally. - * - * @todo This is also yet another case where it would make more sense to split - * up the {@link SelectNode} type. - * - * @todo pending split up of {@link SelectNode} type: regardless of other - * improvements to setter APIs, only ` values are written in option order (consistent with JavaRosa, also tested elsewhere with strings)', + }, + { selectValues: [], expectedValue: [] }, + ])('setValue ($selectValues)', ({ selectValues, expectedValue, reason }) => { + const selectValuesDescription = JSON.stringify(selectValues.map(String)); + const expectedValueDescription = JSON.stringify(expectedValue.map(String)); + const reasonDescription = reason == null ? '' : `(${reason})`; + const description = + `sets ${selectValuesDescription}, resulting in value ${expectedValueDescription} ${reasonDescription}`.trim(); + + it(description, () => { + scenario.proposed_answerTypedSelect('/root/int-value', 'int', selectValues); + answer = getTypedSelectNodeAnswer('/root/int-value', 'int'); + + expectTypeOf(answer.value).toEqualTypeOf(); + + expect(answer.value).toEqual(expectedValue); + + const expectedStringValue = expectedValue.map(String).join(' '); + + expect(answer.stringValue).toBe(expectedStringValue); + }); + }); + + interface SetIntSelectErrorCase { + readonly selectValues: readonly bigint[]; + } + + // TODO: unsure if there's anything to test here! The values won't be + // set because they're not available in the select's `valueOptions`. + describe.skip.each([ + { selectValues: [-2_147_483_649n] }, + { selectValues: [2_147_483_648n] }, + { selectValues: [-2_147_483_649n, 2_147_483_648n] }, + { selectValues: [2_147_483_649n, -2_147_483_648n] }, + ])('integer value out of specified bounds ($selectType)', ({ selectValues }) => { + const selectValuesDescription = JSON.stringify(selectValues.map(String)); + + it(`fails to set ${selectValuesDescription}`, () => { + let caught: unknown; + + try { + scenario.proposed_answerTypedSelect('/root/int-value', 'int', selectValues); + answer = getTypedSelectNodeAnswer('/root/int-value', 'int'); + } catch (error) { + caught = error; + } + + expect(caught, `Value was set to ${answer.stringValue}`).toBeInstanceOf(Error); + }); + }); + }); + }); + + describe('type="decimal"', () => { + let answer: SelectNodeAnswer<'decimal'>; + + beforeEach(() => { + answer = getTypedSelectNodeAnswer('/root/decimal-value', 'decimal'); + }); + + it('has a runtime value which is an array of a single number value', () => { + expect(answer.value).toBeInstanceOf(Array); + expect(answer.value.length).toBe(1); + expect(answer.value[0]).toBeTypeOf('number'); + }); + + it('has a readonly number[] static type', () => { + expectTypeOf(answer.value).toEqualTypeOf(); + }); + + it('has a number populated value', () => { + expect(answer.value).toEqual([45.67]); + }); + + it('has an empty array blank value', () => { + scenario.answer(selectRelevancePath, 'no'); + answer = getTypedSelectNodeAnswer('/root/decimal-value', 'decimal'); + expect(answer.value).toEqual([]); + }); + + describe('setting decimal values', () => { + interface SetDecimalSelectValueCase { + readonly selectValues: readonly number[]; + readonly expectedValue: readonly number[]; + readonly reason?: string; + } + + it.each([ + { selectValues: [89], expectedValue: [89] }, + { selectValues: [10], expectedValue: [10] }, + { selectValues: [45.67, 23.4], expectedValue: [45.67, 23.4] }, + { + selectValues: [23.4, 45.67], + expectedValue: [45.67, 23.4], + reason: + '`. +// That has built-in support for ticks (via ``, see +// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/range#adding_tick_marks). +// +// The example on that page for labeling ticks may also suggest another way to +// address presentation of the actual value (which would be more mobile-friendly +// as well). + +.range-control-container { + --track-size: 0.25rem; + --track-value-emphasis: 0.125rem; + --thumb-size: 1.25rem; + --tick-size: 0.125rem; + + position: relative; + + .range-bound { + position: absolute; + line-height: 1; + } + + &.horizontal { + height: var(--track-size); + padding: 0 0.5rem 2lh 0.5rem; + + .range-bound { + bottom: 0; + } + + .range-min { + left: 0; + } + + .range-max { + right: 0; + } + } + + &.vertical { + width: var(--track-size); + padding: 0.5lh 3rem 0.5lh; + + // Vertical appearance is centered. Consistent with + // https://docs.getodk.org/form-question-types/#vertical-range-widget + margin: 0 auto; + + .range-bound { + right: 0; + } + + .range-min { + top: 0; + } + + .range-max { + bottom: 0; + } + } +} + +// = track (full-width; full-height in vertical orientation) +.p-slider { + background-color: rgb(from var(--primary-color) r g b / 25%); + + // = emphasized range between `min` and current value + :deep(.p-slider-range) { + border-radius: calc((var(--track-size) + var(--track-value-emphasis)) / 2); + } + + &.p-slider-horizontal { + height: var(--track-size); + border-radius: calc(var(--track-size) / 2); + + :deep(.p-slider-range) { + top: calc(var(--track-value-emphasis) * -0.5); + height: calc(var(--track-size) + var(--track-value-emphasis)); + } + } + + &.p-slider-vertical { + // No idea what this actually should be! I picked a size that "felt right". + // + // TODO: if we do want a fixed height, we should account for dynamic + // viewport height, form header height, any other constraining factors(?) + height: 12rem; + width: var(--track-size); + border-radius: calc(var(--track-size) / 2); + + :deep(.p-slider-range) { + left: calc(var(--track-value-emphasis) * -0.5); + width: calc(var(--track-size) + var(--track-value-emphasis)); + } + } + + // ≈ `` "thumb" + :deep(.p-slider-handle) { + --thumb-shadow: 0px 3px 1px -2px rgba(0, 0, 0, 0.2), 0px 2px 2px 0px rgba(0, 0, 0, 0.14), + 0px 1px 5px 0px rgba(0, 0, 0, 0.12); + + width: var(--thumb-size); + height: var(--thumb-size); + border-radius: 50%; + box-shadow: var(--thumb-shadow); + + // No clue why PrimeVue has a default `transform` style to shrink this! + transform: none; + + z-index: 1; + + &:focus-visible { + box-shadow: + var(--thumb-shadow), + 0 0 1px 10px rgb(from var(--primary-color) r g b / 20%); + } + } +} + diff --git a/packages/web-forms/src/components/controls/Range/RangeSlider.vue b/packages/web-forms/src/components/controls/Range/RangeSlider.vue new file mode 100644 index 000000000..2cc29d33f --- /dev/null +++ b/packages/web-forms/src/components/controls/Range/RangeSlider.vue @@ -0,0 +1,62 @@ + diff --git a/packages/web-forms/src/components/controls/Select1Control.vue b/packages/web-forms/src/components/controls/Select1Control.vue index 3aaba66b3..15d58abf1 100644 --- a/packages/web-forms/src/components/controls/Select1Control.vue +++ b/packages/web-forms/src/components/controls/Select1Control.vue @@ -1,5 +1,5 @@ diff --git a/packages/web-forms/src/components/controls/SelectNControl.vue b/packages/web-forms/src/components/controls/SelectNControl.vue index 4862d420e..51363bc20 100644 --- a/packages/web-forms/src/components/controls/SelectNControl.vue +++ b/packages/web-forms/src/components/controls/SelectNControl.vue @@ -1,5 +1,5 @@ diff --git a/packages/web-forms/src/components/widgets/LikertWidget.vue b/packages/web-forms/src/components/widgets/LikertWidget.vue index 3aab801d5..f872775f9 100644 --- a/packages/web-forms/src/components/widgets/LikertWidget.vue +++ b/packages/web-forms/src/components/widgets/LikertWidget.vue @@ -1,8 +1,12 @@ diff --git a/packages/web-forms/src/components/widgets/MultiselectDropdown.vue b/packages/web-forms/src/components/widgets/MultiselectDropdown.vue index a8f721b66..74fbc5b0d 100644 --- a/packages/web-forms/src/components/widgets/MultiselectDropdown.vue +++ b/packages/web-forms/src/components/widgets/MultiselectDropdown.vue @@ -1,21 +1,31 @@ - diff --git a/packages/web-forms/src/components/widgets/SearchableDropdown.vue b/packages/web-forms/src/components/widgets/SearchableDropdown.vue index df1330cbb..19394c79c 100644 --- a/packages/web-forms/src/components/widgets/SearchableDropdown.vue +++ b/packages/web-forms/src/components/widgets/SearchableDropdown.vue @@ -1,15 +1,31 @@ - @@ -20,9 +36,9 @@ const getOptionLabel = (o: SelectItem) => { :filter="question.appearances.autocomplete" :auto-filter-focus="true" :model-value="question.currentState.value[0]" - :options="question.currentState.valueOptions" + :options="options" :option-label="getOptionLabel" - @update:model-value="setSelect1Value" + @update:model-value="selectValue" @change="$emit('change')" /> diff --git a/packages/web-forms/src/lib/format/selectOptionId.ts b/packages/web-forms/src/lib/format/selectOptionId.ts new file mode 100644 index 000000000..c8a0186a1 --- /dev/null +++ b/packages/web-forms/src/lib/format/selectOptionId.ts @@ -0,0 +1,5 @@ +import type { SelectItem, SelectNode, ValueType } from '@getodk/xforms-engine'; + +export const selectOptionId = (node: SelectNode, optionItem: SelectItem): string => { + return `${node.nodeId}_${optionItem.asString}`; +}; diff --git a/packages/web-forms/tests/components/FormQuestion.test.ts b/packages/web-forms/tests/components/FormQuestion.test.ts index d025a966b..917fc5954 100644 --- a/packages/web-forms/tests/components/FormQuestion.test.ts +++ b/packages/web-forms/tests/components/FormQuestion.test.ts @@ -1,7 +1,7 @@ import InputControl from '@/components/controls/Input/InputControl.vue'; import SelectControl from '@/components/controls/SelectControl.vue'; import UnsupportedControl from '@/components/controls/UnsupportedControl.vue'; -import type { SelectNode } from '@getodk/xforms-engine'; +import type { AnySelectNode } from '@getodk/xforms-engine'; import { mount } from '@vue/test-utils'; import { describe, expect, it } from 'vitest'; import FormQuestion from '../../src/components/FormQuestion.vue'; @@ -12,7 +12,7 @@ const mountComponent = async (formPath: string, questionNumber: number) => { return mount(FormQuestion, { props: { - question: xform.currentState.children[questionNumber] as SelectNode, + question: xform.currentState.children[questionNumber] as AnySelectNode, }, global: globalMountOptions, }); diff --git a/packages/web-forms/tests/components/SelectControl.test.ts b/packages/web-forms/tests/components/SelectControl.test.ts index 53f4ca54d..d700a105c 100644 --- a/packages/web-forms/tests/components/SelectControl.test.ts +++ b/packages/web-forms/tests/components/SelectControl.test.ts @@ -1,81 +1,367 @@ import SelectControl from '@/components/controls/SelectControl.vue'; -import type { SelectNode } from '@getodk/xforms-engine'; +import type { AnyNode, AnySelectNode, RootNode } from '@getodk/xforms-engine'; import { DOMWrapper, mount } from '@vue/test-utils'; -import { describe, expect, it } from 'vitest'; -import { getReactiveForm, globalMountOptions } from '../helpers'; +import { assert, beforeEach, describe, expect, it } from 'vitest'; +import { getReactiveForm, globalMountOptions } from '../helpers.ts'; -const mountComponent = async ( - questionNumber: number, - formPath = '1-static-selects.xml', - submitPressed = false -) => { - const xform = await getReactiveForm(formPath); +const findSelectNodeByReference = (node: AnyNode, reference: string): AnySelectNode | null => { + const nodeReference = node.currentState.reference; + + if (nodeReference === reference) { + assert(node.nodeType === 'select'); + + return node; + } + + const children = node.currentState.children ?? []; + + for (const child of children) { + const result = findSelectNodeByReference(child, reference); + + if (result != null) { + return result; + } + } + + return null; +}; + +const getSelectNodeByReference = (root: RootNode, reference: string): AnySelectNode => { + const result = findSelectNodeByReference(root, reference); + + assert(result != null); + + return result; +}; - const component = mount(SelectControl, { +interface MountComponentOptions { + readonly submitPressed?: boolean; +} + +type MountedComponent = ReturnType; + +const mountComponent = (selectNode: AnySelectNode, options?: MountComponentOptions) => { + const { submitPressed = false } = options ?? {}; + + return mount(SelectControl, { props: { - question: xform.currentState.children[questionNumber] as SelectNode, + question: selectNode, + }, + global: { + ...globalMountOptions, + provide: { submitPressed }, }, - global: { ...globalMountOptions, provide: { submitPressed } }, attachTo: document.body, }); +}; + +const expectSelectedValuesState = ( + selectNode: AnySelectNode, + expectedValues: readonly string[] +) => { + const actualValues = selectNode.currentState.value; + + expect(actualValues.length).toBe(expectedValues.length); + + for (const expectedValue of expectedValues) { + expect(actualValues).toContain(expectedValue); + } +}; + +const expectSelectedValueState = (selectNode: AnySelectNode, value: string | null) => { + if (value == null) { + return expectSelectedValuesState(selectNode, []); + } - return { xform, component }; + return expectSelectedValuesState(selectNode, [value]); +}; + +const findMenu = (component: MountedComponent): DOMWrapper | null => { + const [menu = null] = component.findAll('.p-dropdown-items, .p-multiselect-items'); + + return menu; +}; + +const openMenu = async ( + component: MountedComponent, + controlElement: DOMWrapper +): Promise> => { + let menu = findMenu(component); + + if (menu == null) { + await controlElement.trigger('click'); + + menu = findMenu(component); + } + + assert(menu != null); + + return menu; +}; + +const getMenuItems = async ( + component: MountedComponent, + controlElement: DOMWrapper +): Promise>> => { + const menu = await openMenu(component, controlElement); + + return menu.findAll('.p-dropdown-item, .p-multiselect-item'); +}; + +const getMenuItem = async ( + component: MountedComponent, + controlElement: DOMWrapper, + label: string +): Promise | null> => { + const menuItems = await getMenuItems(component, controlElement); + + return menuItems.find((menuItem) => menuItem.text() === label) ?? null; }; describe('SelectControl', () => { - it('shows radio buttons for select1', async () => { - const { xform, component } = await mountComponent(0); - const nodeId = xform.currentState.children[0].nodeId; + describe('select1', () => { + describe('no appearance (radio controls)', () => { + let root: RootNode; + let selectNode: AnySelectNode; + let component: MountedComponent; + let cherry: DOMWrapper; + let mango: DOMWrapper; + + beforeEach(async () => { + root = await getReactiveForm('select-control.xml'); + selectNode = getSelectNodeByReference(root, '/data/no-appearance/sel1'); + component = mountComponent(selectNode); + + const nodeId = selectNode.nodeId; + + cherry = component.find(`input[id="${nodeId}_cherry"]`); + mango = component.find(`input[id="${nodeId}_mango"]`); + + expectSelectedValueState(selectNode, 'cherry'); + }); + + it('renders radio buttons for items of with no appearance', () => { + expect(cherry.element.type).toEqual('radio'); + expect(mango.element.type).toEqual('radio'); + }); + + it('renders the selected value as checked', () => { + expect(cherry.element.checked).toBe(true); + expect(mango.element.checked).toBe(false); + }); + + it('updates the selection when selecting a rendered radio', async () => { + await mango.trigger('click'); + + expectSelectedValueState(selectNode, 'mango'); + expect(cherry.element.checked).toBe(false); + expect(mango.element.checked).toBe(true); + }); + }); - const cherry: DOMWrapper = component.find(`input[id="${nodeId}_cherry"]`); - const mango: DOMWrapper = component.find(`input[id="${nodeId}_mango"]`); + interface Select1MenuAppearanceCase { + readonly appearance: string; + readonly reference: string; + } - expect(cherry.element.type).toEqual('radio'); - expect(cherry.element.checked).toBe(true); + describe.each([ + { appearance: 'minimal', reference: '/data/minimal' }, + { appearance: 'search', reference: '/data/search' }, + { appearance: 'minimal search', reference: '/data/minimal_search' }, + ])('dropdown with appearance: $appearance', ({ reference }) => { + let root: RootNode; + let selectNode: AnySelectNode; + let component: MountedComponent; + let controlElement: DOMWrapper; - await mango.trigger('click'); + beforeEach(async () => { + root = await getReactiveForm('select-control.xml'); + selectNode = getSelectNodeByReference(root, reference); + component = mountComponent(selectNode); + controlElement = component.find(`[id="${selectNode.nodeId}"]`); + }); - expect(cherry.element.checked).toBe(false); - expect(mango.element.checked).toBe(true); + it('renders as a dropdown', () => { + // TODO: this is tied to PrimeVue's classes, we should control this! + expect(controlElement.classes()).toContain('p-dropdown'); + }); + + const expectedOptionLabels = ['Karachi', 'Toronto', 'Lahore', 'Islamabad', 'Vancouver']; + + it('shows option items in a menu', async () => { + const menuItems = await getMenuItems(component, controlElement); + + expect(menuItems.length).toBe(expectedOptionLabels.length); + + for (const [index, expectedLabel] of expectedOptionLabels.entries()) { + const menuItem = menuItems[index]; + + assert(menuItem != null); + + expect(menuItem.text()).toBe(expectedLabel); + } + }); + + it.each(expectedOptionLabels)('selects option: %s', async (expectedOptionLabel) => { + let menuItem = await getMenuItem(component, controlElement, expectedOptionLabel); + + assert(menuItem != null); + expectSelectedValueState(selectNode, null); + expect(menuItem.classes()).not.toContain('p-highlight'); + + await menuItem.trigger('click'); + + menuItem = await getMenuItem(component, controlElement, expectedOptionLabel); + assert(menuItem != null); + + expectSelectedValueState(selectNode, expectedOptionLabel.toLowerCase()); + expect(menuItem.classes()).toContain('p-highlight'); + }); + }); }); - it('shows checkboxes for select many', async () => { - const { xform, component } = await mountComponent(1); - const nodeId = xform.currentState.children[1].nodeId; + describe('select', () => { + describe('no appearance (checkbox controls)', () => { + let root: RootNode; + let selectNode: AnySelectNode; + let component: MountedComponent; + let watermelon: DOMWrapper; + let peach: DOMWrapper; + + beforeEach(async () => { + root = await getReactiveForm('select-control.xml'); + selectNode = getSelectNodeByReference(root, '/data/no-appearance/sel'); + component = mountComponent(selectNode); + + const nodeId = selectNode.nodeId; + + watermelon = component.find(`input[id="${nodeId}_watermelon"]`); + peach = component.find(`input[id="${nodeId}_peach"]`); + + expectSelectedValuesState(selectNode, ['peach']); + }); + + it('renders checkboxes for items of `, calling this method is - * additive, i.e. it will include the item in its - * {@link SelectNodeState.value}. - * - For fields defined with an XForms ``, calling this method will - * replace the selection (if any). + * - if the provided value is `null`, the current selection is cleared; ELSE + * - the provided value is selected in place of any currently selected values. * - * @todo @see {@link InputNode.setValue} re: write restrictions - * @todo @see {@link SelectNodeState.value} re: breaking up the types + * This setter is most useful for {@link SelectNode}s associated with an + * XForms + * {@link https://getodk.github.io/xforms-spec/#body-elements | ``} + * control. */ - select(item: SelectItem): RootNode; + selectValue(this: SelectNode, value: SelectItemValue | null): RootNode; + selectValue(this: SelectNode, value: SelectItemValue | null): RootNode; /** - * For use by a client to remove an item from the node's - * {@link SelectNodeState.value}. + * Selects any number of {@link values}, as provided by any number of + * {@link SelectItem.value}s. Calling this setter replaces the currently + * selected value(s, if any). If called with an empty array, the current + * selection is cleared. + * + * This setter is most useful for {@link SelectNode}s associated with an + * XForms + * {@link https://getodk.github.io/xforms-spec/#body-elements | `` to match the order + * they appear in the control's (potentially filtered) `` (or list of + * ``s, for forms defining those inline). + * + * @todo The `` control, having semantics very similar to + * `` controls. + * + * This generalizes the application of a {@link SharedValueCodec} implementation + * over individual select values, where those values are serialized as a + * whitespace-separated list. All other encoding and decoding logic is deferred + * to the provided {@link baseCodec}, ensuring that select value types are + * treated consistently with the same underlying data types for other controls. + */ +export class MultipleValueSelectCodec extends ValueArrayCodec { + constructor(baseCodec: SharedValueCodec) { + const encodeValue: CodecEncoder> = (value) => { + return value.map(baseCodec.encodeValue).join(' '); + }; + const decodeValue: CodecDecoder> = (value) => { + const instanceValues = xmlXPathWhitespaceSeparatedList(value, { + ignoreEmpty: true, + }); + + return instanceValues.map(this.decodeItemValue); + }; + + super(baseCodec, encodeValue, decodeValue); + } +} diff --git a/packages/xforms-engine/src/lib/codecs/select/SingleValueSelectCodec.ts b/packages/xforms-engine/src/lib/codecs/select/SingleValueSelectCodec.ts new file mode 100644 index 000000000..500015f48 --- /dev/null +++ b/packages/xforms-engine/src/lib/codecs/select/SingleValueSelectCodec.ts @@ -0,0 +1,73 @@ +import type { ValueType } from '../../../client/ValueType.ts'; +import type { SharedValueCodec } from '../getSharedValueCodec.ts'; +import { ValueArrayCodec, type RuntimeItemValue, type RuntimeValues } from '../ValueArrayCodec.ts'; +import { type CodecDecoder, type CodecEncoder } from '../ValueCodec.ts'; +import type { MultipleValueSelectCodec } from './MultipleValueSelectCodec.ts'; + +// prettier-ignore +export type SingleValueSelectRuntimeValues = + | readonly [] + | readonly [RuntimeItemValue]; + +/** + * @see {@link encodeValueFactory} + */ +// prettier-ignore +type SingleValueSelectCodecValues = + | RuntimeValues + | SingleValueSelectRuntimeValues; + +/** + * @todo This is more permissive than it should be, allowing an array of any + * length. It's not clear whether a runtime check **MUST** happen here, but + * if we identify bugs where `` controls are somehow allowing more + * than one value to be set, this is where we'd start looking. The check is + * skipped for now, to reduce performance overhead. + */ +const encodeValueFactory = ( + baseCodec: SharedValueCodec +): CodecEncoder> => { + return (values) => { + const [value] = values; + + if (value == null) { + return ''; + } + + return baseCodec.encodeValue(value); + }; +}; + +/** + * Value codec implementation for `` controls. + * + * Note: this implementation is a specialization of the same principles + * underlying {@link MultipleValueSelectCodec}. It is implemented separately: + * + * 1. to address a semantic difference between ``/`` items and itemsets // immediately upon its definition. effect(() => { select.currentState.valueOptions.forEach((option) => { - const { label, value } = option; - const labelStates = observedLabelStatesByValue.upsert(value, () => []); + const label = option.label; + const stringValue = option.asString; + const labelStates = observedLabelStatesByValue.upsert(stringValue, () => []); if (label == null) { - expect.fail(`Select item with value ${value} has no label`); + expect.fail(`Select item with value ${stringValue} has no label`); } labelStates.push(label.asString);