();
+ });
+
+ it('has a date populated value', () => {
+ expect(answer.value).to.deep.equal(Temporal.PlainDateTime.from('2025-12-20T13:45:55'));
+ expect(answer.stringValue).toEqual('2025-12-20T13:45:55');
+ });
+
+ it('has an null as blank value', () => {
+ scenario.answer(inputRelevancePath, 'no');
+ answer = getTypedInputNodeAnswer('/root/date-value', 'date');
+ expect(answer.value).toBeNull();
+ expect(answer.stringValue).toBe('');
+ });
+
+ it.each([
+ '13:30:55',
+ '2025-23-23',
+ 'ZYX',
+ '2025-03-07T14:30:00+invalid',
+ ])('has null when incorrect value is passed', (expression) => {
+ scenario.answer('/root/date-value', expression);
+ answer = getTypedInputNodeAnswer('/root/date-value', 'date');
+ expect(answer.value).toBeNull();
+ expect(answer.stringValue).toBe('');
+ });
+
+ it.each([
+ {
+ expression: '2025-03-07',
+ expectedAsObject: Temporal.PlainDate.from('2025-03-07'),
+ expectedAsText: '2025-03-07',
+ },
+ {
+ expression: '2025-03-07T14:30:00-08:00',
+ expectedAsObject: Temporal.PlainDateTime.from('2025-03-07T14:30:00-08:00'),
+ expectedAsText: '2025-03-07T14:30:00',
+ },
+ {
+ expression: '2025-03-07T14:30:00-08:00[America/Los_Angeles]',
+ expectedAsObject: Temporal.ZonedDateTime.from('2025-03-07T14:30:00-08:00[America/Los_Angeles]'),
+ expectedAsText: '2025-03-07T14:30:00-08:00[America/Los_Angeles]',
+ },
+ {
+ expression: '2025-03-07T14:30:00Z',
+ expectedAsObject: Temporal.ZonedDateTime.from('2025-03-07T14:30:00[UTC]'),
+ expectedAsText: '2025-03-07T14:30:00Z',
+ },
+ {
+ expression: '2025-03-07T14:30:00',
+ expectedAsObject: Temporal.PlainDateTime.from('2025-03-07T14:30:00'),
+ expectedAsText: '2025-03-07T14:30:00',
+ },
+ ])(
+ 'sets value with valid date',
+ ({ expression, expectedAsObject, expectedAsText }) => {
+ scenario.answer('/root/date-value', expression);
+ answer = getTypedInputNodeAnswer('/root/date-value', 'date');
+ expect(answer.value).to.deep.equal(expectedAsObject);
+ expect(answer.stringValue).toEqual(expectedAsText);
+ }
+ );
+ });
});
describe('casting fractional values to int', () => {
diff --git a/packages/xforms-engine/src/lib/codecs/Date/Datetime.ts b/packages/xforms-engine/src/lib/codecs/Date/Datetime.ts
index 3c3bd5c6..b8443a0e 100644
--- a/packages/xforms-engine/src/lib/codecs/Date/Datetime.ts
+++ b/packages/xforms-engine/src/lib/codecs/Date/Datetime.ts
@@ -22,10 +22,10 @@ export class Datetime {
}
if (ISO_DATE_TIME_LIKE_PATTERN.test(value)) {
- return Temporal.ZonedDateTime.from(value);
+ return Temporal.PlainDateTime.from(value);
}
- return Temporal.PlainDateTime.from(value);
+ return Temporal.ZonedDateTime.from(value);
} catch {
// TODO: should we throw when codec cannot interpret the value?
return null;
From 987cc6e4b01d639600283506df98366c6258747a Mon Sep 17 00:00:00 2001
From: latin-panda <66472237+latin-panda@users.noreply.github.com>
Date: Fri, 7 Mar 2025 22:16:31 -0600
Subject: [PATCH 08/20] Adjusts css for date picker
---
.../controls/Input/InputControl.vue | 8 +--
.../components/controls/Input/InputDate.vue | 59 +++++++++++++++++--
2 files changed, 59 insertions(+), 8 deletions(-)
diff --git a/packages/web-forms/src/components/controls/Input/InputControl.vue b/packages/web-forms/src/components/controls/Input/InputControl.vue
index 9af6e817..c9a7c8ec 100644
--- a/packages/web-forms/src/components/controls/Input/InputControl.vue
+++ b/packages/web-forms/src/components/controls/Input/InputControl.vue
@@ -28,9 +28,6 @@ provide('isInvalid', isInvalid);
-
-
-
@@ -43,12 +40,15 @@ provide('isInvalid', isInvalid);
+
+
+
-
+
+import { computed } from 'vue';
import Calendar from 'primevue/calendar';
-import { ref } from 'vue';
+import type { DateInputNode } from '@getodk/xforms-engine';
+
+interface InputDateProps {
+ readonly question: DateInputNode;
+}
+
+const props = defineProps();
+
+const value = computed({
+ get: () => {
+ const temporalValue = props.question.currentState.value;
+
+ if (temporalValue == null) {
+ return null;
+ }
+
+ if (temporalValue instanceof Temporal.ZonedDateTime) {
+ return temporalValue.toInstant().toJSDate();
+ }
+
+ // For PlainDate and PlainDateTime, use ISO string with UTC assumption
+ const time = (temporalValue instanceof Temporal.PlainDate ? 'T00:00:00Z' : 'Z');
+ return new Date(temporalValue.toString() + time);
+ },
+ set: (newDate) => {
+ props.question.setValue(newDate);
+ },
+});
+
+const isDisabled = computed(() => props.question.currentState.readonly === true);
-const value = ref(new Date('1990-02-23'));
-
+
-
From 6b22347227d9cfbe294a0e1513b18b11422d1d18 Mon Sep 17 00:00:00 2001
From: latin-panda <66472237+latin-panda@users.noreply.github.com>
Date: Fri, 7 Mar 2025 22:24:19 -0600
Subject: [PATCH 09/20] Date Note
---
.../web-forms/src/components/controls/Input/InputDate.vue | 8 ++++----
.../web-forms/src/components/controls/NoteControl.vue | 8 ++++++--
2 files changed, 10 insertions(+), 6 deletions(-)
diff --git a/packages/web-forms/src/components/controls/Input/InputDate.vue b/packages/web-forms/src/components/controls/Input/InputDate.vue
index f7d373b0..187d187a 100644
--- a/packages/web-forms/src/components/controls/Input/InputDate.vue
+++ b/packages/web-forms/src/components/controls/Input/InputDate.vue
@@ -1,6 +1,7 @@
-
+
diff --git a/packages/web-forms/src/components/controls/NoteControl.vue b/packages/web-forms/src/components/controls/NoteControl.vue
index f438872e..0d6ccaa1 100644
--- a/packages/web-forms/src/components/controls/NoteControl.vue
+++ b/packages/web-forms/src/components/controls/NoteControl.vue
@@ -74,7 +74,7 @@ const value = computed(() => {
-
+
{{ value.toString() }}
diff --git a/packages/xforms-engine/src/lib/codecs/Date/Datetime.ts b/packages/xforms-engine/src/lib/codecs/Date/Datetime.ts
deleted file mode 100644
index 95852e3a..00000000
--- a/packages/xforms-engine/src/lib/codecs/Date/Datetime.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-import { Temporal } from 'temporal-polyfill';
-import {
- ISO_DATE_LIKE_PATTERN,
- ISO_DATE_OR_DATE_TIME_LIKE_PATTERN,
- ISO_DATE_TIME_LIKE_PATTERN,
-} from '@getodk/common/constants/datetime.ts';
-
-export type DatetimeRuntimeValue =
- | Temporal.PlainDate
- | Temporal.PlainDateTime
- | Temporal.ZonedDateTime
- | null;
-
-export type DatetimeInputValue =
- | Date
- | Temporal.PlainDate
- | Temporal.PlainDateTime
- | Temporal.ZonedDateTime
- | string
- | null;
-
-export class Datetime {
- static parseString(value: string): DatetimeRuntimeValue {
- if (
- value == null ||
- typeof value !== 'string' ||
- !ISO_DATE_OR_DATE_TIME_LIKE_PATTERN.test(value)
- ) {
- return null;
- }
-
- try {
- if (ISO_DATE_LIKE_PATTERN.test(value) && !value.includes('T')) {
- return Temporal.PlainDate.from(value);
- }
-
- if (ISO_DATE_TIME_LIKE_PATTERN.test(value)) {
- return Temporal.PlainDateTime.from(value);
- }
-
- return Temporal.ZonedDateTime.from(value);
- } catch {
- // TODO: should we throw when codec cannot interpret the value?
- return null;
- }
- }
-
- static toDateString(value: DatetimeInputValue): string {
- if (value == null) {
- return '';
- }
-
- try {
- if (
- value instanceof Temporal.ZonedDateTime ||
- value instanceof Temporal.PlainDateTime ||
- value instanceof Temporal.PlainDate
- ) {
- return value.toString();
- }
-
- if (value instanceof Date) {
- return Temporal.ZonedDateTime.from({
- timeZoneId: Temporal.Now.timeZoneId(),
- year: value.getFullYear(),
- month: value.getMonth() + 1,
- day: value.getDate(),
- hour: value.getHours(),
- minute: value.getMinutes(),
- second: value.getSeconds(),
- millisecond: value.getMilliseconds(),
- }).toString();
- }
-
- const parsedValue = Datetime.parseString(value);
- return parsedValue == null ? '' : parsedValue.toString();
- } catch {
- // TODO: should we throw when codec cannot interpret the value?
- return '';
- }
- }
-}
diff --git a/packages/xforms-engine/src/lib/codecs/Date/DatetimeValueCodec.ts b/packages/xforms-engine/src/lib/codecs/Date/DatetimeValueCodec.ts
deleted file mode 100644
index 358738f1..00000000
--- a/packages/xforms-engine/src/lib/codecs/Date/DatetimeValueCodec.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { type CodecDecoder, type CodecEncoder, ValueCodec } from '../ValueCodec.ts';
-import { Datetime, type DatetimeInputValue, type DatetimeRuntimeValue } from './Datetime.ts';
-
-export class DatetimeValueCodec extends ValueCodec<
- 'date',
- DatetimeRuntimeValue,
- DatetimeInputValue
-> {
- constructor() {
- const encodeValue: CodecEncoder = (value) => {
- return Datetime.toDateString(value);
- };
-
- const decodeValue: CodecDecoder = (value: string) => {
- return Datetime.parseString(value);
- };
-
- super('date', encodeValue, decodeValue);
- }
-}
diff --git a/packages/xforms-engine/src/lib/codecs/DateValueCodec.ts b/packages/xforms-engine/src/lib/codecs/DateValueCodec.ts
new file mode 100644
index 00000000..24e99d82
--- /dev/null
+++ b/packages/xforms-engine/src/lib/codecs/DateValueCodec.ts
@@ -0,0 +1,90 @@
+import { ISO_DATE_OR_DATE_TIME_NO_OFFSET_PATTERN } from '@getodk/common/constants/datetime.ts';
+import { Temporal } from 'temporal-polyfill';
+import { type CodecDecoder, type CodecEncoder, ValueCodec } from './ValueCodec.ts';
+
+export type DatetimeRuntimeValue = Temporal.PlainDate | null;
+
+export type DatetimeInputValue =
+ | Date
+ | Temporal.PlainDate
+ | Temporal.PlainDateTime
+ | Temporal.ZonedDateTime
+ | string
+ | null;
+
+/**
+ * Parses a string in the format 'YYYY-MM-DD' or 'YYYY-MM-DDTHH:MM:SS' (no offset)
+ * into a Temporal.PlainDate.
+ * @param value - The string to parse.
+ * @returns A {@link DatetimeRuntimeValue}
+ */
+const parseString = (value: string): DatetimeRuntimeValue => {
+ if (value == null || typeof value !== 'string') {
+ return null;
+ }
+
+ if (!ISO_DATE_OR_DATE_TIME_NO_OFFSET_PATTERN.test(value)) {
+ return null;
+ }
+
+ try {
+ const dateOnly = value.split('T')[0];
+ if (dateOnly == null) {
+ return null;
+ }
+
+ return Temporal.PlainDate.from(dateOnly);
+ } catch {
+ // TODO: should we throw when codec cannot interpret the value?
+ return null;
+ }
+};
+
+/**
+ * Converts a date-like value ({@link DatetimeInputValue}) to a 'YYYY-MM-DD' string.
+ * @param value - The value to convert.
+ * @returns A date string or empty string if invalid.
+ */
+const toDateString = (value: DatetimeInputValue): string => {
+ if (value == null) {
+ return '';
+ }
+
+ try {
+ if (value instanceof Temporal.PlainDate) {
+ return value.toString();
+ }
+
+ if (value instanceof Temporal.PlainDateTime || value instanceof Temporal.ZonedDateTime) {
+ return value.toPlainDate().toString();
+ }
+
+ if (value instanceof Date) {
+ return Temporal.PlainDate.from({
+ year: value.getFullYear(),
+ month: value.getMonth() + 1,
+ day: value.getDate(),
+ }).toString();
+ }
+
+ const parsed = parseString(String(value));
+ return parsed == null ? '' : parsed.toString();
+ } catch {
+ // TODO: should we throw when codec cannot interpret the value?
+ return '';
+ }
+};
+
+export class DateValueCodec extends ValueCodec<'date', DatetimeRuntimeValue, DatetimeInputValue> {
+ constructor() {
+ const encodeValue: CodecEncoder = (value) => {
+ return toDateString(value);
+ };
+
+ const decodeValue: CodecDecoder = (value: string) => {
+ return parseString(value);
+ };
+
+ super('date', encodeValue, decodeValue);
+ }
+}
diff --git a/packages/xforms-engine/src/lib/codecs/getSharedValueCodec.ts b/packages/xforms-engine/src/lib/codecs/getSharedValueCodec.ts
index 7966067e..a9f03b28 100644
--- a/packages/xforms-engine/src/lib/codecs/getSharedValueCodec.ts
+++ b/packages/xforms-engine/src/lib/codecs/getSharedValueCodec.ts
@@ -1,6 +1,6 @@
import type { ValueType } from '../../client/ValueType.ts';
-import type { DatetimeInputValue, DatetimeRuntimeValue } from './Date/Datetime.ts';
-import { DatetimeValueCodec } from './Date/DatetimeValueCodec.ts';
+import type { DatetimeInputValue, DatetimeRuntimeValue } from './DateValueCodec.ts';
+import { DateValueCodec } from './DateValueCodec.ts';
import {
DecimalValueCodec,
type DecimalInputValue,
@@ -65,7 +65,7 @@ export const sharedValueCodecs: SharedValueCodecs = {
int: new IntValueCodec(),
decimal: new DecimalValueCodec(),
boolean: new ValueTypePlaceholderCodec('boolean'),
- date: new DatetimeValueCodec(),
+ date: new DateValueCodec(),
time: new ValueTypePlaceholderCodec('time'),
dateTime: new ValueTypePlaceholderCodec('dateTime'),
geopoint: new GeopointValueCodec(),
From 1f907634d43665ba6bf740309f841c1f53899292 Mon Sep 17 00:00:00 2001
From: latin-panda <66472237+latin-panda@users.noreply.github.com>
Date: Mon, 10 Mar 2025 19:20:44 -0600
Subject: [PATCH 13/20] Adds changeset
---
.changeset/neat-numbers-change.md | 10 ++++++++++
1 file changed, 10 insertions(+)
create mode 100644 .changeset/neat-numbers-change.md
diff --git a/.changeset/neat-numbers-change.md b/.changeset/neat-numbers-change.md
new file mode 100644
index 00000000..4a6cbaeb
--- /dev/null
+++ b/.changeset/neat-numbers-change.md
@@ -0,0 +1,10 @@
+---
+'@getodk/xforms-engine': minor
+'@getodk/web-forms': minor
+'@getodk/scenario': patch
+'@getodk/common': minor
+'@getodk/xpath': minor
+---
+
+- Support for date questions with no appearance
+- Support for date notes
From 41271ac071ef349d9ce9da405091ec101bb3bbd1 Mon Sep 17 00:00:00 2001
From: latin-panda <66472237+latin-panda@users.noreply.github.com>
Date: Mon, 10 Mar 2025 19:56:23 -0600
Subject: [PATCH 14/20] Refinements
---
packages/scenario/test/bind-types.test.ts | 4 ++--
.../src/components/controls/Input/InputControl.vue | 8 ++++++--
.../xforms-engine/src/lib/codecs/DateValueCodec.ts | 12 ++++++++++--
3 files changed, 18 insertions(+), 6 deletions(-)
diff --git a/packages/scenario/test/bind-types.test.ts b/packages/scenario/test/bind-types.test.ts
index 05ebdf46..a2345568 100644
--- a/packages/scenario/test/bind-types.test.ts
+++ b/packages/scenario/test/bind-types.test.ts
@@ -228,7 +228,7 @@ describe('Data () type support', () => {
answer = getTypedModelValueNodeAnswer('/root/date-value', 'date');
});
- it('has a ZonedDateTime | null static type', () => {
+ it('has a PlainDate | null static type', () => {
expectTypeOf(answer.value).toEqualTypeOf();
});
@@ -652,7 +652,7 @@ describe('Data () type support', () => {
answer = getTypedInputNodeAnswer('/root/date-value', 'date');
});
- it('has a ZonedDateTime | null static type', () => {
+ it('has a PlainDate | null static type', () => {
expectTypeOf(answer.value).toEqualTypeOf();
});
diff --git a/packages/web-forms/src/components/controls/Input/InputControl.vue b/packages/web-forms/src/components/controls/Input/InputControl.vue
index c9a7c8ec..6c60a37c 100644
--- a/packages/web-forms/src/components/controls/Input/InputControl.vue
+++ b/packages/web-forms/src/components/controls/Input/InputControl.vue
@@ -19,6 +19,11 @@ const props = defineProps();
const doneAnswering = ref(false);
const submitPressed = inject('submitPressed');
const isInvalid = computed(() => props.node.validationState.violation?.valid === false);
+const hideIconError = computed(() => {
+ // Excluding these node type, since no error icon is needed in the input box like other input types.
+ // TODO: Refactor to allow each input type to determine how errors are displayed.
+ return !['geopoint', 'date'].includes(props.node.valueType);
+});
provide('doneAnswering', doneAnswering);
provide('isInvalid', isInvalid);
@@ -47,8 +52,7 @@ provide('isInvalid', isInvalid);
-
-
+
{
/**
* Converts a date-like value ({@link DatetimeInputValue}) to a 'YYYY-MM-DD' string.
+ * TODO: Datetimes with a valid timezone offset are treated as errors.
+ * User research is needed to determine whether the date should honor
+ * the timezone or be truncated to the yyyy-mm-dd format only.
+ *
* @param value - The value to convert.
* @returns A date string or empty string if invalid.
*/
const toDateString = (value: DatetimeInputValue): string => {
- if (value == null) {
+ if (value == null || value instanceof Temporal.ZonedDateTime) {
return '';
}
@@ -55,7 +63,7 @@ const toDateString = (value: DatetimeInputValue): string => {
return value.toString();
}
- if (value instanceof Temporal.PlainDateTime || value instanceof Temporal.ZonedDateTime) {
+ if (value instanceof Temporal.PlainDateTime) {
return value.toPlainDate().toString();
}
From bc52acb724560600187a6e0377b073983f0cf75c Mon Sep 17 00:00:00 2001
From: latin-panda <66472237+latin-panda@users.noreply.github.com>
Date: Mon, 10 Mar 2025 20:00:27 -0600
Subject: [PATCH 15/20] Removes unneeded dependency
---
packages/web-forms/package.json | 1 -
1 file changed, 1 deletion(-)
diff --git a/packages/web-forms/package.json b/packages/web-forms/package.json
index 88471f1b..add7b2e4 100644
--- a/packages/web-forms/package.json
+++ b/packages/web-forms/package.json
@@ -75,7 +75,6 @@
"vue": "^3.3.4"
},
"dependencies": {
- "temporal-polyfill": "^0.2.5",
"vue-draggable-plus": "^0.6.0"
}
}
From c60a97cf732f4b930410512d3e2d03de263ad86e Mon Sep 17 00:00:00 2001
From: latin-panda <66472237+latin-panda@users.noreply.github.com>
Date: Tue, 11 Mar 2025 20:00:19 -0600
Subject: [PATCH 16/20] Makes form's error message closable and makes it the
same context as other elements for z-index to work.
---
.../web-forms/src/components/OdkWebForm.vue | 23 +++++++++++--------
1 file changed, 14 insertions(+), 9 deletions(-)
diff --git a/packages/web-forms/src/components/OdkWebForm.vue b/packages/web-forms/src/components/OdkWebForm.vue
index e06d01d2..07aef32c 100644
--- a/packages/web-forms/src/components/OdkWebForm.vue
+++ b/packages/web-forms/src/components/OdkWebForm.vue
@@ -8,7 +8,6 @@ import { initializeForm, type FetchFormAttachment, type RootNode } from '@getodk
import Button from 'primevue/button';
import Card from 'primevue/card';
import PrimeMessage from 'primevue/message';
-import type { ComponentPublicInstance } from 'vue';
import { computed, getCurrentInstance, provide, reactive, ref, watchEffect } from 'vue';
import { FormInitializationError } from '../lib/error/FormInitializationError.ts';
import FormLoadFailureDialog from './Form/FormLoadFailureDialog.vue';
@@ -105,6 +104,8 @@ const emit = defineEmits();
const odkForm = ref();
const submitPressed = ref(false);
+const floatingErrorActive = ref(false);
+const showValidationError = ref(false);
const initializeFormError = ref();
initializeForm(props.formXml, {
@@ -125,18 +126,18 @@ const handleSubmit = () => {
const root = odkForm.value;
if (root?.validationState.violations?.length === 0) {
+ floatingErrorActive.value = false;
// eslint-disable-next-line @typescript-eslint/no-floating-promises
emitSubmit(root);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
emitSubmitChunked(root);
} else {
+ floatingErrorActive.value = true;
submitPressed.value = true;
document.scrollingElement?.scrollTo(0, 0);
}
};
-const validationErrorMessagePopover = ref(null);
-
provide('submitPressed', submitPressed);
const validationErrorMessage = computed(() => {
@@ -149,10 +150,10 @@ const validationErrorMessage = computed(() => {
});
watchEffect(() => {
- if (submitPressed.value && validationErrorMessage.value) {
- (validationErrorMessagePopover.value?.$el as HTMLElement)?.showPopover?.();
+ if (floatingErrorActive.value && validationErrorMessage.value?.length) {
+ showValidationError.value = true;
} else {
- (validationErrorMessagePopover.value?.$el as HTMLElement)?.hidePopover?.();
+ showValidationError.value = false;
}
});
@@ -186,8 +187,9 @@ watchEffect(() => {
:class="{ 'submit-pressed': submitPressed }"
>