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

feat(#311): adds support for default date question type #328

Open
wants to merge 20 commits into
base: main
Choose a base branch
from
Open
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
10 changes: 10 additions & 0 deletions .changeset/neat-numbers-change.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,7 @@ export const ISO_DATE_OR_DATE_TIME_LIKE_PATTERN = new RegExp(
'$',
].join('')
);

export const ISO_DATE_OR_DATE_TIME_NO_OFFSET_PATTERN = new RegExp(
['^', ISO_DATE_LIKE_SUBPATTERN, `(T${ISO_TIME_LIKE_SUBPATTERN})?`, '$'].join('')
);
77 changes: 77 additions & 0 deletions packages/common/src/fixtures/date/date.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?xml version="1.0"?>
<h:html xmlns="http://www.w3.org/2002/xforms" xmlns:h="http://www.w3.org/1999/xhtml"
xmlns:ev="http://www.w3.org/2001/xml-events" xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:jr="http://openrosa.org/javarosa" xmlns:orx="http://openrosa.org/xforms"
xmlns:odk="http://www.opendatakit.org/xforms">
<h:head>
<h:title>Date</h:title>
<model odk:xforms-version="1.0.0">
<itext>
<translation lang="English (en)">
<text id="/data/dates/survey_date:label">
<value>When are you filling out this survey?</value>
</text>
<text id="/data/dates/date_of_birth:label">
<value>When were you born?</value>
</text>
<text id="/data/dates/fruits_date:label">
<value>When was the last time you ate fruits?</value>
</text>
<text id="/data/dates/vegetables_date:label">
<value>When was the last time you ate vegetables?</value>
</text>
</translation>
<translation lang="French (fr)">
<text id="/data/dates/survey_date:label">
<value>Quand allez-vous remplir ce questionnaire?</value>
</text>
<text id="/data/dates/date_of_birth:label">
<value>Quand êtes-vous né(e) ?</value>
</text>
<text id="/data/dates/fruits_date:label">
<value>Quand est-ce que vous avez mangé des fruits pour la dernière fois ?</value>
</text>
<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>
</translation>
</itext>
<instance>
<data id="date" version="2025020401">
<dates>
<survey_date/>
<date_of_birth/>
<fruits_date/>
<vegetables_date/>
</dates>
<meta>
<instanceID/>
</meta>
</data>
</instance>
<bind nodeset="/data/dates/survey_date" type="date" required="true()"/>
<bind nodeset="/data/dates/date_of_birth" type="date" required="false()"/>
<bind nodeset="/data/dates/fruits_date" type="date" required="false()"
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/meta/instanceID" type="string" readonly="true()" jr:preload="uid"/>
</model>
</h:head>
<h:body>
<group appearance="field-list" ref="/data/dates">
<input ref="/data/dates/survey_date">
<label ref="jr:itext('/data/dates/survey_date:label')"/>
</input>
<input ref="/data/dates/date_of_birth">
<label ref="jr:itext('/data/dates/date_of_birth:label')"/>
</input>
<input ref="/data/dates/fruits_date">
<label ref="jr:itext('/data/dates/fruits_date:label')"/>
</input>
<input ref="/data/dates/vegetables_date">
<label ref="jr:itext('/data/dates/vegetables_date:label')"/>
</input>
</group>
</h:body>
</h:html>
11 changes: 8 additions & 3 deletions packages/common/src/fixtures/notes/2-all-possible-notes.xml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
<read_only_int />
<read_only_int_value>3</read_only_int_value>
<note_calc_decimal_from_int />
<geopoint-note>38.253094215699576 21.756382658677467 0 150</geopoint-note>
<date_note>2025-12-21T23:30:05</date_note>
<geopoint_note>38.253094215699576 21.756382658677467 0 150</geopoint_note>
</group>
<meta>
<instanceID />
Expand All @@ -38,8 +39,9 @@
<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/group/date_note" type="date" readonly="true()" />
<bind nodeset="/data/group/geopoint_note" type="geopoint" readonly="true()" />
<bind nodeset="/data/meta/instanceID" type="string" readonly="true()" jr:preload="uid" />
<bind nodeset="/data/group/geopoint-note" type="geopoint" readonly="true()" />
</model>
</h:head>
<h:body>
Expand Down Expand Up @@ -78,7 +80,10 @@
<input ref="/data/group/note_calc_decimal_from_int">
<label>A note with decimal type calculated from int</label>
</input>
<input ref="/data/group/geopoint-note">
<input ref="/data/group/date_note">
<label>A note with date type</label>
</input>
<input ref="/data/group/geopoint_note">
<label>A note with geopoint type</label>
</input>
</group>
Expand Down
3 changes: 3 additions & 0 deletions packages/scenario/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,5 +54,8 @@
"solid-js": "^1.9.3",
"vite": "^5.4.11",
"vitest": "^2.1.8"
},
"dependencies": {
"temporal-polyfill": "^0.2.5"
}
}
90 changes: 87 additions & 3 deletions packages/scenario/test/bind-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
title,
} from '@getodk/common/test/fixtures/xform-dsl/index.ts';
import type { ValueType } from '@getodk/xforms-engine';
import { Temporal } from 'temporal-polyfill';
import { assert, beforeEach, describe, expect, expectTypeOf, it } from 'vitest';
import { intAnswer } from '../src/answer/ExpectedIntAnswer.ts';
import { InputNodeAnswer } from '../src/answer/InputNodeAnswer.ts';
Expand Down Expand Up @@ -38,13 +39,15 @@ describe('Data (<bind type>) type support', () => {
t('int-value', '123'),
t('decimal-value', '45.67'),
t('geopoint-value', '38.25146813817506 21.758421137528785 0 0'),
t('date-value', '1999-11-23T23:30:05'),
)
),
bind('/root/string-value').type('string').relevant(modelNodeRelevanceExpression),
bind('/root/implicit-string-value').relevant(modelNodeRelevanceExpression),
bind('/root/int-value').type('int').relevant(modelNodeRelevanceExpression),
bind('/root/decimal-value').type('decimal').relevant(modelNodeRelevanceExpression),
bind('/root/geopoint-value').type('geopoint').relevant(modelNodeRelevanceExpression)
bind('/root/geopoint-value').type('geopoint').relevant(modelNodeRelevanceExpression),
bind('/root/date-value').type('date').relevant(modelNodeRelevanceExpression)
)
),
body(
Expand Down Expand Up @@ -211,12 +214,34 @@ describe('Data (<bind type>) type support', () => {
});
});

it('has an null as blank value', () => {
it('has a null as blank value', () => {
scenario.answer(modelNodeRelevancePath, 'no');
answer = getTypedModelValueNodeAnswer('/root/geopoint-value', 'geopoint');
expect(answer.value).toBeNull();
});
});

describe('type="date"', () => {
let answer: ModelValueNodeAnswer<'date'>;

beforeEach(() => {
answer = getTypedModelValueNodeAnswer('/root/date-value', 'date');
});

it('has a PlainDate | null static type', () => {
expectTypeOf(answer.value).toEqualTypeOf<Temporal.PlainDate | null>();
});

it('has a date populated value', () => {
expect(answer.value).to.deep.equal(Temporal.PlainDate.from('1999-11-23'));
});

it('has a null as blank value', () => {
scenario.answer(modelNodeRelevancePath, 'no');
answer = getTypedModelValueNodeAnswer('/root/date-value', 'date');
expect(answer.value).toBeNull();
});
});
});

describe('inputs', () => {
Expand All @@ -238,13 +263,15 @@ describe('Data (<bind type>) type support', () => {
t('int-value', '123'),
t('decimal-value', '45.67'),
t('geopoint-value', '38.25146813817506 21.758421137528785 1000 25'),
t('date-value', '2025-12-20'),
)
),
bind('/root/string-value').type('string').relevant(inputRelevanceExpression),
bind('/root/implicit-string-value').relevant(inputRelevanceExpression),
bind('/root/int-value').type('int').relevant(inputRelevanceExpression),
bind('/root/decimal-value').type('decimal').relevant(inputRelevanceExpression),
bind('/root/geopoint-value').type('geopoint').relevant(inputRelevanceExpression)
bind('/root/geopoint-value').type('geopoint').relevant(inputRelevanceExpression),
bind('/root/date-value').type('date').relevant(inputRelevanceExpression)
)
),
body(
Expand All @@ -254,6 +281,7 @@ describe('Data (<bind type>) type support', () => {
input('/root/int-value'),
input('/root/decimal-value'),
input('/root/geopoint-value'),
input('/root/date-value'),
)
);

Expand Down Expand Up @@ -616,6 +644,62 @@ describe('Data (<bind type>) type support', () => {
}
);
});

describe('type="date"', () => {
let answer: InputNodeAnswer<'date'>;

beforeEach(() => {
answer = getTypedInputNodeAnswer('/root/date-value', 'date');
});

it('has a PlainDate | null static type', () => {
expectTypeOf(answer.value).toEqualTypeOf<Temporal.PlainDate | null>();
});

it('has a date populated value', () => {
expect(answer.value).to.deep.equal(Temporal.PlainDate.from('2025-12-20'));
expect(answer.stringValue).toEqual('2025-12-20');
});

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',
'2025-03-07T14:30:00-08:00',
'2025-03-07T14:30:00Z',
])('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-14',
expectedAsObject: Temporal.PlainDate.from('2025-03-14'),
expectedAsText: '2025-03-14',
},
{
expression: '2025-12-21T14:30:00',
expectedAsObject: Temporal.PlainDate.from('2025-12-21'),
expectedAsText: '2025-12-21',
},
])('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', () => {
Expand Down
17 changes: 17 additions & 0 deletions packages/web-forms/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,23 @@ It uses the [PrimeVue component library](https://primevue.org/).

We are using a customized version of the Material Light Indigo theme provided by PrimeVue.

#### Z-Index Layering System

This package uses a centralized `z-index` layering system to manage UI stacking order, defined in `src/assets/css/z-index.css`. Custom properties (e.g., `--z-index-error-banner`) ensure elements like floating error messages, form controls, and overlays stack correctly without overlap.

- **Key Layers**:

- `--z-index-base: 0` (background)
- `--z-index-form-content: 10` (inputs, buttons)
- `--z-index-form-floating: 20` (highlights, tooltips)
- `--z-index-error-banner: 30` (floating errors)
- `--z-index-overlay: 100` (modals)
- `--z-index-topmost: 1000` (loaders, notifications)

- **Usage**: Apply with `z-index: var(--z-index-error-banner);` on positioned elements (e.g., `position: absolute`).

See [src/assets/css/z-index.css](src/assets/css/z-index.css) for full details.

### Icons

We use **Material Icons** using IcoMoon to select a subset of icons in order to minimize the size. The font files are located in [`./src/assets/fonts/`](./src/assets/fonts/), and the CSS is [`./src/assets/css/icomoon.css`](/src/assets/css/icomoon.css). Our IcoMoon definition is in the root directory of this package at [`./icomoon.json`](./icomoon.json).
Expand Down
39 changes: 39 additions & 0 deletions packages/web-forms/src/assets/css/z-index.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Z-Index Layering System
*
* This project uses a centralized z-index layering system to manage the stacking order of UI
* elements, preventing overlap issues and ensuring maintainability. CSS custom properties are
* defined with meaningful names and a logical sequence, making it easy to understand and adjust
* the stacking order.
*
* Purpose:
* - Avoid z-index conflicts by assigning predefined layers to UI components.
* - Provide clarity on stacking priorities for developers and maintainers.
* - Allow flexibility for future additions without restructuring existing values.
*
* Example:
* .error-banner-placeholder {
* z-index: var(--z-index-error-banner);
* position: absolute;
* }
*/

:root {
/* Base layer for static content (e.g., page content, form background) */
--z-index-base: 0;

/* Standard form controls (e.g., inputs, labels, buttons) */
--z-index-form-content: 10;

/* Floating form elements (e.g., validation highlights, tooltips) */
--z-index-form-floating: 20;

/* Floating error messages or banners above the form */
--z-index-error-banner: 30;

/* Overlays like modals, popups, or dialogs above form content */
--z-index-overlay: 100;

/* Critical UI elements (e.g., loading spinners, toast notifications) */
--z-index-topmost: 1000;
}
Loading
Loading