Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for <select> and <select1> value types, partial support for <range> #273

Open
wants to merge 20 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
6a68552
NodeDefinition is an abstract class, with simpler base property types
eyelidlessness Dec 15, 2024
e8aaba2
TriggerNode < BaseValueNode, TriggerControl < ValueNode
eyelidlessness Dec 20, 2024
be2922c
NoteNode < BaseValueNode, Note < ValueNode
eyelidlessness Dec 22, 2024
57d26cf
scenario: update to account for `NoteNode` being generic over its val…
eyelidlessness Dec 22, 2024
43cc144
web-forms (Vue UI): update for `NoteNode` being generic over its valu…
eyelidlessness Dec 22, 2024
3105ec9
engine: support for RangeNode
eyelidlessness Dec 26, 2024
e6705eb
scenario (fix): `PositionalEvent.from` was overly broad
eyelidlessness Dec 26, 2024
d7804c1
scenario: address engine introduction of `RangeNode`/`AnyRangeNode`
eyelidlessness Dec 26, 2024
f895e3d
web-forms (Vue UI): partial support for <range> controls
eyelidlessness Dec 26, 2024
32ba785
engine: less opinionated client base type for BaseValueNode.valueOptions
eyelidlessness Jan 8, 2025
67374a7
engine: more ergonomic/future-proof select value setters
eyelidlessness Jan 10, 2025
c39bdb6
scenario: update to reflect improved ergonomics of SelectNode setters…
eyelidlessness Jan 10, 2025
2813ef5
web-forms (Vue UI): update to reflect improved ergonomics of SelectNo…
eyelidlessness Jan 10, 2025
48d275b
engine: SelectNode.currentState.value from `SelectItem[]` -> `string[]`
eyelidlessness Jan 10, 2025
c9fd24f
scenario: integrate `SelectNode.currentState.value` as strings vs Sel…
eyelidlessness Jan 10, 2025
a6bc529
web-forms (Vue UI): integrate `SelectNode.currentState.value` as stri…
eyelidlessness Jan 10, 2025
9f8aead
engine: SelectNode support for value types
eyelidlessness Jan 13, 2025
c05b33a
scenario: integrate value type support for `SelectNode`, expand coverage
eyelidlessness Jan 13, 2025
da06ebc
web-forms (Vue UI): integrate value type suport for selects
eyelidlessness Jan 13, 2025
a0c3da2
Changeset
eyelidlessness Jan 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,13 @@ export default tseslint.config(
'prettier-vue/prettier': 'error',
'vue/html-indent': ['error', 'tab'],
'vue/html-comment-indent': ['error', 'tab'],
'vue/script-indent': ['error', 'tab'],
'vue/script-indent': [
'error',
'tab',
{
switchCase: 1,
},
],
// should be based on the printWidth
'vue/max-attributes-per-line': 'off',
'vue/no-undef-components': 'error',
Expand Down
44 changes: 44 additions & 0 deletions packages/common/src/fixtures/controls/range-controls.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<h:html xmlns="http://www.w3.org/2002/xforms"
xmlns:h="http://www.w3.org/1999/xhtml">

<h:head>
<h:title>Range controls</h:title>
<model>
<instance>
<root id="range-controls">
<range-decimal>1.0</range-decimal>
<range-int>2</range-int>

<vertical>
<v-decimal>1.25</v-decimal>
<v-int>1</v-int>
</vertical>
</root>
</instance>
<bind nodeset="/root/range-decimal" type="xsd:decimal" />
<bind nodeset="/root/range-int" type="int" />
<bind nodeset="/root/vertical/v-decimal" type="decimal" />
<bind nodeset="/root/vertical/v-int" type="xsd:int" />
</model>
</h:head>

<h:body>
<range ref="/root/range-decimal" start="-2.0" end="2.0" step="0.5">
<label>Range control (decimal)</label>
</range>
<range ref="/root/range-int" start="-6" end="4" step="2">
<label>Range control (int)</label>
</range>

<group ref="/root/vertical">
<range ref="/root/vertical/v-decimal" start="-5.0" end="7.0" step="0.5"
appearance="vertical">
<label>Range control (decimal)</label>
</range>
<range ref="/root/vertical/v-int" start="-3" end="4" step="3" appearance="vertical no-ticks">
<label>Range control (int)</label>
</range>
</group>
</h:body>

</h:html>
6 changes: 6 additions & 0 deletions packages/common/src/fixtures/notes/2-all-possible-notes.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
<note_w_ex_appearance />
<read_only_int />
<read_only_int_value>3</read_only_int_value>
<note_calc_decimal_from_int />
</group>
<meta>
<instanceID />
Expand All @@ -34,6 +35,8 @@
<bind nodeset="/data/group/note_w_ex_appearance" readonly="true()" type="string" />
<bind nodeset="/data/group/read_only_int" type="int" readonly="true()" />
<bind nodeset="/data/group/read_only_int_value" type="int" readonly="true()" />
<bind nodeset="/data/group/note_calc_decimal_from_int" type="decimal"
calculate="/data/group/read_only_int_value + 1.5" readonly="true()" />
<bind nodeset="/data/meta/instanceID" type="string" readonly="true()" jr:preload="uid" />
</model>
</h:head>
Expand Down Expand Up @@ -70,6 +73,9 @@
<input ref="/data/group/read_only_int_value">
<label>A readonly integer with value</label>
</input>
<input ref="/data/group/note_calc_decimal_from_int">
<label>A note with decimal type calculated from int</label>
</input>
</group>
</h:body>
</h:html>
6 changes: 3 additions & 3 deletions packages/scenario/src/answer/NoteNodeAnswer.ts
Original file line number Diff line number Diff line change
@@ -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<NoteNode> {
export class NoteNodeAnswer extends ValueNodeAnswer<AnyNoteNode> {
get stringValue(): string {
return this.node.currentState.value ?? '';
return this.node.currentState.instanceValue;
}
}
17 changes: 17 additions & 0 deletions packages/scenario/src/answer/RangeNodeAnswer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { RangeNode, RangeValue, RangeValueType } from '@getodk/xforms-engine';
import { ValueNodeAnswer } from './ValueNodeAnswer.ts';

export class RangeNodeAnswer<V extends RangeValueType = RangeValueType> extends ValueNodeAnswer<
RangeNode<V>
> {
readonly valueType: V;
readonly stringValue: string;
readonly value: RangeValue<V>;

constructor(node: RangeNode<V>) {
super(node);
this.valueType = node.valueType;
this.stringValue = this.node.currentState.instanceValue;
this.value = this.node.currentState.value;
}
}
4 changes: 2 additions & 2 deletions packages/scenario/src/answer/ValueNodeAnswer.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { AnyLeafNode, InputNode, ModelValueNode } from '@getodk/xforms-engine';
import type { AnyLeafNode, InputNode, ModelValueNode, RangeNode } from '@getodk/xforms-engine';
import { ComparableAnswer } from './ComparableAnswer.ts';

export type ValueNode = AnyLeafNode | InputNode | ModelValueNode;
export type ValueNode = AnyLeafNode | InputNode | ModelValueNode | RangeNode;

export abstract class ValueNodeAnswer<Node extends ValueNode = ValueNode> extends ComparableAnswer {
constructor(readonly node: Node) {
Expand Down
18 changes: 15 additions & 3 deletions packages/scenario/src/jr/event/PositionalEvent.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { assertInstanceType } from '@getodk/common/lib/runtime-types/instance-predicates.ts';
import type {
AnyInputNode,
AnyNoteNode,
AnyRangeNode,
AnyUnsupportedControlNode,
GroupNode,
NoteNode,
RepeatInstanceNode,
RepeatRangeUncontrolledNode,
RootNode,
Expand All @@ -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
| AnyNoteNode
| SelectNode
| AnyInputNode
| AnyRangeNode
| TriggerNode
| AnyUnsupportedControlNode;

Expand Down Expand Up @@ -69,7 +71,7 @@ const singletons = new Map<AnyEventNode, UnknownPositionalEvent>();
export abstract class PositionalEvent<Type extends PositionalEventType> {
static from<Type extends PositionalEventType, Inst extends PositionalEvent<Type>>(
this: PositionalEventConstructor<Type, Inst>,
node: PositionalEventNode<Type>
node: PositionalEventConstructorNode<Type, Inst>
): Inst {
let singleton = singletons.get(node);

Expand All @@ -96,3 +98,13 @@ type PositionalEventConstructor<
Type extends PositionalEventType,
Inst extends PositionalEvent<Type>,
> = new (node: Inst['node']) => Inst;

// prettier-ignore
type PositionalEventConstructorNode<
Type extends PositionalEventType,
Inst extends PositionalEvent<Type>
> =
PositionalEventConstructor<Type, Inst> extends
(new (node: infer T) => Inst)
? T
: never;
55 changes: 55 additions & 0 deletions packages/scenario/src/jr/event/RangeQuestionEvent.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
4 changes: 4 additions & 0 deletions packages/scenario/src/jr/event/getPositionalEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { GroupEvent } from './GroupEvent.ts';
import { InputQuestionEvent } from './InputQuestionEvent.ts';
import { NoteQuestionEvent } from './NoteQuestionEvent.ts';
import { PromptNewRepeatEvent } from './PromptNewRepeatEvent.ts';
import { RangeQuestionEvent } from './RangeQuestionEvent.ts';
import { RepeatInstanceEvent } from './RepeatInstanceEvent.ts';
import { SelectQuestionEvent } from './SelectQuestionEvent.ts';
import { TriggerQuestionEvent } from './TriggerQuestionEvent.ts';
Expand All @@ -16,6 +17,7 @@ import { UnsupportedControlQuestionEvent } from './UnsupportedControlQuestionEve
export type AnyQuestionEvent =
| InputQuestionEvent
| NoteQuestionEvent
| RangeQuestionEvent
| SelectQuestionEvent
| TriggerQuestionEvent
| UnsupportedControlQuestionEvent;
Expand Down Expand Up @@ -87,6 +89,8 @@ export const getPositionalEvents = (instanceRoot: RootNode): PositionalEvents =>
return TriggerQuestionEvent.from(node);

case 'range':
return RangeQuestionEvent.from(node);

case 'rank':
case 'upload':
return UnsupportedControlQuestionEvent.from(node);
Expand Down
12 changes: 8 additions & 4 deletions packages/web-forms/src/components/FormQuestion.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@
import type {
AnyControlNode,
AnyInputNode,
AnyNoteNode,
AnyUnsupportedControlNode,
NoteNode,
SelectNode,
} from '@getodk/xforms-engine';
import { inject } from 'vue';
import InputControl from './controls/Input/InputControl.vue';
import NoteControl from './controls/NoteControl.vue';
import RangeControl from './controls/Range/RangeControl.vue';
import SelectControl from './controls/SelectControl.vue';
import TriggerControl from './controls/TriggerControl.vue';
import UnsupportedControl from './controls/UnsupportedControl.vue';
Expand All @@ -17,9 +18,10 @@ type ControlNode = AnyControlNode | AnyUnsupportedControlNode;

defineProps<{ question: ControlNode }>();

const isInputNode = (n: ControlNode): n is AnyInputNode => n.nodeType === 'input';
const isSelectNode = (n: ControlNode): n is SelectNode => n.nodeType === 'select';
const isNoteNode = (n: ControlNode): n is NoteNode => n.nodeType === 'note';
const isInputNode = (node: ControlNode): node is AnyInputNode => node.nodeType === 'input';
const isSelectNode = (node: ControlNode): node is SelectNode => node.nodeType === 'select';
const isNoteNode = (node: ControlNode): node is AnyNoteNode => node.nodeType === 'note';
const isRangeNode = (node: ControlNode) => node.nodeType === 'range';
const isTriggerNode = (node: ControlNode) => node.nodeType === 'trigger';

const submitPressed = inject('submitPressed');
Expand All @@ -39,6 +41,8 @@ const submitPressed = inject('submitPressed');

<NoteControl v-else-if="isNoteNode(question)" :question="question" />

<RangeControl v-else-if="isRangeNode(question)" :node="question" />

<TriggerControl v-else-if="isTriggerNode(question)" :question="question" />

<UnsupportedControl v-else :question="question" />
Expand Down
66 changes: 62 additions & 4 deletions packages/web-forms/src/components/controls/NoteControl.vue
Original file line number Diff line number Diff line change
@@ -1,16 +1,74 @@
<script setup lang="ts">
import type { NoteNode } from '@getodk/xforms-engine';
import { UnreachableError } from '@getodk/common/lib/error/UnreachableError.ts';
import type { AnyNoteNode } from '@getodk/xforms-engine';
import { computed } from 'vue';
import ControlText from '../ControlText.vue';

defineProps<{ question: NoteNode }>();
const props = defineProps<{ question: AnyNoteNode }>();

// prettier-ignore
type TextRenderableValue =
| bigint
| boolean
| number
| string
| null
| undefined;

type AssertTextRenderableValue = (value: unknown) => asserts value is TextRenderableValue;

const assertTextRenderableValue: AssertTextRenderableValue = (value) => {
if (value == null) {
return;
}

switch (typeof value) {
case 'string':
case 'number':
case 'bigint':
lognaturel marked this conversation as resolved.
Show resolved Hide resolved
case 'boolean':
return;

default:
throw new Error(`Expected text-renderable value type. Got: ${typeof value}`);
}
};

const value = computed((): TextRenderableValue => {
const { question } = props;

switch (question.valueType) {
case 'string':
case 'int':
case 'decimal':
return question.currentState.value;

case 'boolean':
case 'date':
case 'time':
case 'dateTime':
case 'geopoint':
case 'geotrace':
case 'geoshape':
case 'binary':
case 'barcode':
case 'intent':
assertTextRenderableValue(question.currentState.value);

return question.currentState.value;

default:
throw new UnreachableError(question);
}
});
</script>

<template>
<div class="note-control">
<ControlText :question="question" />

<div v-if="question.currentState.value" class="note-value">
{{ question.currentState.value }}
<div v-if="value != null" class="note-value">
{{ value }}
</div>
</div>
</template>
Expand Down
Loading