Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 8 additions & 0 deletions .changeset/empty-views-stop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@getodk/xforms-engine': minor
'@getodk/web-forms': minor
'@getodk/scenario': patch
'@getodk/common': patch
---

Adds time question type
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ This section is auto generated. Please update `feature-matrix.json` and then run
<summary>

<!-- prettier-ignore -->
##### Question types (basic functionality)<br/>🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩⬜⬜⬜ 82\%
##### Question types (basic functionality)<br/>🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩⬜⬜⬜ 85\%

</summary>
<br/>
Expand Down Expand Up @@ -63,7 +63,7 @@ This section is auto generated. Please update `feature-matrix.json` and then run
| video | ✅ |
| [file](https://github.com/getodk/web-forms/issues/370) | |
| date | ✅ |
| time | |
| time | |
| datetime | |
| rank | ✅ |
| csv-external | ✅ |
Expand Down
2 changes: 1 addition & 1 deletion feature-matrix.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"video": "✅",
"[file](https://github.com/getodk/web-forms/issues/370)": "",
"date": "✅",
"time": "",
"time": "",
"datetime": "",
"rank": "✅",
"csv-external": "✅",
Expand Down
19 changes: 12 additions & 7 deletions packages/common/src/constants/datetime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ const ISO_DATE_LIKE_SUBPATTERN = '\\d{4}-\\d{2}-\\d{2}';

export const ISO_DATE_LIKE_PATTERN = new RegExp(`^${ISO_DATE_LIKE_SUBPATTERN}(?=T|$)`);

const ISO_TIME_LIKE_SUBPATTERN = `(${[
'\\d{2}:\\d{2}:\\d{2}\\.\\d+',
'\\d{2}:\\d{2}:\\d{2}',
'\\d{2}:\\d{2}',
'\\d{2}',
].join('|')})`;
const STRICT_TIME_FORMATS = ['\\d{2}:\\d{2}:\\d{2}\\.\\d+', '\\d{2}:\\d{2}:\\d{2}'];

const STRICT_ISO_TIME_SUBPATTERN = `(${[...STRICT_TIME_FORMATS].join('|')})`;

const ISO_TIME_LIKE_SUBPATTERN = `(${[...STRICT_TIME_FORMATS, '\\d{2}:\\d{2}', '\\d{2}'].join(
'|'
)})`;

export const ISO_TIME_LIKE_PATTERN = new RegExp(`^${ISO_TIME_LIKE_SUBPATTERN}$`);

Expand All @@ -25,7 +26,7 @@ const ISO_OFFSET_SUBPATTERN = `(${TIMEZONE_OFFSET_SUBPATTERN}|Z)`;
* Validates a timezone offset (e.g., "+01:00", "-12:30") in the format ±HH:MM.
* Ensures hours are between 00 and 14, and minutes are between 00 and 59, matching standard timezone offset ranges.
*/
export const VALID_OFFSET_VALUE = new RegExp('^[+-]([0][0-9]|1[0-4]):([0-5][0-9])$');
export const VALID_OFFSET_VALUE = new RegExp('^([+-]([0][0-9]|1[0-4]):([0-5][0-9])|Z)$', 'i');

export const ISO_DATE_TIME_LIKE_PATTERN = new RegExp(
[
Expand All @@ -50,3 +51,7 @@ export const ISO_DATE_OR_DATE_TIME_LIKE_PATTERN = new RegExp(
export const ISO_DATE_OR_DATE_TIME_NO_OFFSET_PATTERN = new RegExp(
['^', ISO_DATE_LIKE_SUBPATTERN, `(T${ISO_TIME_LIKE_SUBPATTERN})?`, '$'].join('')
);

export const ISO_TIME_WITH_OPTIONAL_OFFSET_PATTERN = new RegExp(
['^', STRICT_ISO_TIME_SUBPATTERN, `(${ISO_OFFSET_SUBPATTERN})?`, '$'].join('')
);
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
<text id="/data/dates/vegetables_date:label">
<value>When was the last time you ate vegetables?</value>
</text>
<text id="/data/dates/time:label">
<value>What time is your first meal?</value>
</text>
</translation>
<translation lang="French (fr)">
<text id="/data/dates/survey_date:label">
Expand All @@ -34,6 +37,9 @@
<text id="/data/dates/vegetables_date:label">
<value>Quand est-ce que vous avez mangé des légumes pour la dernière fois ?</value>
</text>
<text id="/data/dates/time:label">
<value>À quelle heure prends-tu ton premier repas ?</value>
</text>
</translation>
</itext>
<instance>
Expand All @@ -43,6 +49,7 @@
<date_of_birth/>
<fruits_date/>
<vegetables_date/>
<time>22:02:10.562+07:00</time>
</dates>
<meta>
<instanceID/>
Expand All @@ -55,6 +62,7 @@
relevant=" /data/dates/date_of_birth != &quot;&quot;"/>
<bind nodeset="/data/dates/vegetables_date" type="date" required="false()"
relevant=" /data/dates/date_of_birth != &quot;&quot;" readonly="/data/dates/date_of_birth &lt;= today()"/>
<bind nodeset="/data/dates/time" type="time" required="true()" />
<bind nodeset="/data/meta/instanceID" type="string" readonly="true()" jr:preload="uid"/>
</model>
</h:head>
Expand All @@ -72,6 +80,9 @@
<input ref="/data/dates/vegetables_date">
<label ref="jr:itext('/data/dates/vegetables_date:label')"/>
</input>
<input ref="/data/dates/time">
<label ref="jr:itext('/data/dates/time:label')"/>
</input>
</group>
</h:body>
</h:html>
5 changes: 5 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 @@ -20,6 +20,7 @@
<read_only_int_value>3</read_only_int_value>
<note_calc_decimal_from_int />
<date_note>2025-12-21T23:30:05</date_note>
<time_note>22:02:10.562+06:00</time_note>
<geopoint_note>38.253094215699576 21.756382658677467 0 150</geopoint_note>
<geotrace_note>37.7749 -122.4194 0 0; 37.775 -122.419 0 0; 37.774 -122.420 0 0</geotrace_note>
<geoshape_note>37.7749 -122.4194 0 0; 37.775 -122.419 0 0; 37.774 -122.420 0 0; 37.7749 -122.4194 0 0</geoshape_note>
Expand All @@ -42,6 +43,7 @@
<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/group/date_note" type="date" readonly="true()" />
<bind nodeset="/data/group/time_note" type="time" readonly="true()" />
<bind nodeset="/data/group/geopoint_note" type="geopoint" readonly="true()" />
<bind nodeset="/data/group/geotrace_note" type="geotrace" readonly="true()" />
<bind nodeset="/data/group/geoshape_note" type="geoshape" readonly="true()" />
Expand Down Expand Up @@ -87,6 +89,9 @@
<input ref="/data/group/date_note">
<label>A note with date type</label>
</input>
<input ref="/data/group/time_note">
<label>A note with time type</label>
</input>
<input ref="/data/group/geopoint_note">
<label>A note with geopoint type</label>
</input>
Expand Down
135 changes: 135 additions & 0 deletions packages/scenario/test/time-bind-type.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import {
bind,
body,
head,
html,
input,
mainInstance,
model,
t,
title,
} from '@getodk/common/test/fixtures/xform-dsl/index.ts';
import { assert, beforeEach, describe, expect, it } from 'vitest';
import { InputNodeAnswer } from '../src/answer/InputNodeAnswer.ts';
import { ModelValueNodeAnswer } from '../src/answer/ModelValueNodeAnswer.ts';
import { Scenario } from '../src/jr/Scenario.ts';

describe('Time bind type', () => {
const formTitle = 'Time bind types';
const relevancePath = '/root/relevance-trigger';
const relevanceExpression = `${relevancePath} = 'yes'`;

const formDefinition = html(
head(
title(formTitle),
model(
mainInstance(
t(
'root id="time-types"',
t('relevance-trigger', 'yes'),
t('model-only-time', '22:02:55.124'),
t('input-time', '14:02:10')
)
),
bind('/root/model-only-time').type('time').relevant(relevanceExpression),
bind('/root/input-time').type('time').relevant(relevanceExpression)
)
),
body(input(relevancePath), input('/root/input-time'))
);

let scenario: Scenario;
beforeEach(async () => {
scenario = await Scenario.init(formTitle, formDefinition);
});

describe('model-only values', () => {
const getModelAnswer = () => {
const answer = scenario.answerOf('/root/model-only-time');
assert(answer instanceof ModelValueNodeAnswer);
assert(answer.valueType === 'time');
return answer as ModelValueNodeAnswer<'time'>;
};

it('has correct static type', () => {
const answer = getModelAnswer();
expect(answer.value).toBeTypeOf('string');
});

it('has a time populated value', () => {
const answer = getModelAnswer();
expect(answer.value).to.equal('22:02:55.124');
});

it('has null as a blank value when not relevant', () => {
scenario.answer(relevancePath, 'no');
const answer = getModelAnswer();
expect(answer.value).toBeNull();
});
});

describe('inputs', () => {
const getInputAnswer = () => {
const answer = scenario.answerOf('/root/input-time');
assert(answer instanceof InputNodeAnswer);
assert(answer.valueType === 'time');
return answer as InputNodeAnswer<'time'>;
};

it('has correct static type', () => {
const answer = getInputAnswer();
expect(answer.value).toBeTypeOf('string');
});

it('has a time populated value', () => {
const answer = getInputAnswer();
expect(answer.value).to.equal('14:02:10');
expect(answer.stringValue).toEqual('14:02:10');
});

it('has null as a blank value when not relevant', () => {
scenario.answer(relevancePath, 'no');
const answer = getInputAnswer();
expect(answer.value).toBeNull();
expect(answer.stringValue).toBe('');
});

it.each([
'22:02:00.000+07:00',
'22:02:00+07:00',
'22:02:00.000-07:00',
'22:02:00-07:00',
'22:02:00.000Z',
'22:02:00Z',
'22:02:00.000',
'22:02:00',
])('sets value with valid time (%s)', (expression) => {
scenario.answer('/root/input-time', expression);
const answer = getInputAnswer();

expect(answer.value).to.deep.equal(expression);
expect(answer.stringValue).toEqual(expression);
});

it.each([
'25:02:00',
'24:00:00',
'22:60:00',
'22:02:61',
'22:02',
'22',
'22:02:00+25:00',
'22:02:00+invalid',
'22:02:00.Z', // Malformed decimal/Z combination
'noon',
'XX:YY:ZZ',
'2026-02-24T22:02:00',
])('has null when incorrect value %s is passed', (expression) => {
scenario.answer('/root/input-time', expression);
const answer = getInputAnswer();

expect(answer.value).toBeNull();
expect(answer.stringValue).toBe('');
});
});
});
15 changes: 15 additions & 0 deletions packages/web-forms/src/assets/styles/primevue.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
.odk-form {
.p-datepicker-input:disabled,
.p-datepicker-input:disabled + .p-datepicker-input-icon-container {
cursor: not-allowed;
}
}

// Modals and dropdowns are outside the .odk-form class scope.
.p-datepicker-panel,
.p-dialog {
.p-button.p-button-secondary:focus-visible {
outline: none;
outline-offset: unset;
}
}
1 change: 1 addition & 0 deletions packages/web-forms/src/assets/styles/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
--odk-text-color: var(--p-surface-900);
--odk-inverted-text-color: var(--p-surface-0);
--odk-muted-text-color: var(--p-surface-500);
--odk-light-muted-text-color: var(--p-surface-400);

--odk-base-background-color: var(--p-surface-0);
--odk-light-background-color: var(--p-surface-50);
Expand Down
1 change: 1 addition & 0 deletions packages/web-forms/src/components/OdkWebForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,7 @@ onUnmounted(() => {

<style lang="scss">
@use 'primeflex/core/_variables.scss' as pf;
@use '../assets/styles/primevue';

:root {
// This variable is used to assert the breakpoint from PrimeFlex are loaded
Expand Down
16 changes: 15 additions & 1 deletion packages/web-forms/src/components/common/IconSVG.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
mdiCheckCircle,
mdiChevronDown,
mdiChevronUp,
mdiClockTimeThreeOutline,
mdiClose,
mdiCogOutline,
mdiContentSave,
Expand Down Expand Up @@ -52,6 +53,7 @@ const iconMap: Record<string, string> = {
mdiCheckCircle,
mdiChevronDown,
mdiChevronUp,
mdiClockTimeThreeOutline,
mdiClose,
mdiCogOutline,
mdiContentSave,
Expand Down Expand Up @@ -80,7 +82,15 @@ const iconMap: Record<string, string> = {
};

type IconName = keyof typeof iconMap;
type IconVariant = 'base' | 'error' | 'inverted' | 'muted' | 'primary' | 'success' | 'warning';
type IconVariant =
| 'base'
| 'error'
| 'inverted'
| 'light-muted'
| 'muted'
| 'primary'
| 'success'
| 'warning';
type IconSize = 'md' | 'sm';

/**
Expand Down Expand Up @@ -132,6 +142,10 @@ const iconSize = computed(() => props.size ?? 'md');
fill: var(--odk-inverted-text-color);
}

.odk-icon.light-muted path {
fill: var(--odk-light-muted-text-color);
}

.odk-icon.muted path {
fill: var(--odk-muted-text-color);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,5 @@ const emit = defineEmits(['update:visible', 'deleteFeature']);
.p-dialog-footer button {
font-size: var(--odk-base-font-size);
}

button.p-button-secondary:focus-visible {
outline: none;
outline-offset: unset;
}
}
</style>
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import InputGeoMultiPoint from '@/components/form-elements/input/InputGeoMultiPo
import InputInt from '@/components/form-elements/input/InputInt.vue';
import InputNumbersAppearance from '@/components/form-elements/input/InputNumbersAppearance.vue';
import InputText from '@/components/form-elements/input/InputText.vue';
import InputTime from '@/components/form-elements/input/InputTime.vue';
import { IS_FORM_EDIT_MODE } from '@/lib/constants/injection-keys.ts';
import type { AnyInputNode } from '@getodk/xforms-engine';
import { inject } from 'vue';
Expand Down Expand Up @@ -49,6 +50,9 @@ const isFormEditMode = inject(IS_FORM_EDIT_MODE);
<template v-else-if="node.valueType === 'date'">
<InputDate :question="node" />
</template>
<template v-else-if="node.valueType === 'time'">
<InputTime :question="node" />
</template>
<template v-else>
<InputText :node="node" />
</template>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,6 @@ const isDisabled = computed(() => props.question.currentState.readonly === true)
font-size: var(--odk-hint-font-size);
}

.p-datepicker-input:disabled,
.p-datepicker-input:disabled + .p-datepicker-input-icon-container {
cursor: not-allowed;
}

.p-datepicker .p-datepicker-input-icon-container {
top: 19px;

Expand Down
Loading