diff --git a/apps/storybook/.storybook/preview.tsx b/apps/storybook/.storybook/preview.tsx index 0512759..5f6a059 100644 --- a/apps/storybook/.storybook/preview.tsx +++ b/apps/storybook/.storybook/preview.tsx @@ -1,4 +1,5 @@ import '@mantine/core/styles.css'; +import '@mantine/dates/styles.css'; import type { Preview } from '@storybook/react-vite'; import { initialize, mswLoader } from 'msw-storybook-addon'; import { mswHandlers } from './msw-handlers'; diff --git a/apps/storybook/fixtures/fieldMetadata.ts b/apps/storybook/fixtures/fieldMetadata.ts index ec0c3ec..fad1268 100644 --- a/apps/storybook/fixtures/fieldMetadata.ts +++ b/apps/storybook/fixtures/fieldMetadata.ts @@ -20,6 +20,7 @@ const VALUE_TYPE_BY_WIDGET: Record = { time: 'TIME', age: 'AGE', select: 'TEXT', + multiSelect: 'MULTI_TEXT', orgUnit: 'ORGANISATION_UNIT', coordinate: 'COORDINATE', file: 'FILE_RESOURCE', @@ -27,6 +28,8 @@ const VALUE_TYPE_BY_WIDGET: Record = { unsupported: 'REFERENCE', }; +const OPTION_SET_WIDGETS = new Set(['select', 'multiSelect']); + export const makeFieldPsde = ( widgetKind: WidgetKind, overrides: Partial = {} @@ -45,7 +48,7 @@ export const makeFieldPsde = ( }, }; - if (widgetKind === 'select' && base.dataElement) { + if (OPTION_SET_WIDGETS.has(widgetKind) && base.dataElement) { base.dataElement = { ...base.dataElement, optionSet: { @@ -94,9 +97,11 @@ export const TIER1_WIDGET_KINDS = [ 'boolean', 'trueOnly', 'select', + 'multiSelect', 'date', 'time', + 'datetime', 'age', ] as const satisfies readonly WidgetKind[]; -export const STUB_WIDGET_KINDS = ['datetime', 'orgUnit'] as const satisfies readonly WidgetKind[]; +export const STUB_WIDGET_KINDS = ['orgUnit'] as const satisfies readonly WidgetKind[]; diff --git a/apps/storybook/interactions/eventProgramRulesInteractions.ts b/apps/storybook/interactions/eventProgramRulesInteractions.ts index 01cf65f..d49d95c 100644 --- a/apps/storybook/interactions/eventProgramRulesInteractions.ts +++ b/apps/storybook/interactions/eventProgramRulesInteractions.ts @@ -66,23 +66,6 @@ async function typeTriggerText(canvasElement: HTMLElement, value: string) { } } -const MANTINE_BOOLEAN_SEGMENT_VALUE: Record<'Yes' | 'No', string> = { - Yes: 'true', - No: 'false', -}; - -async function clickMantineBooleanSegment(control: HTMLElement, optionLabel: 'Yes' | 'No') { - await userEvent.click(within(control).getByText(optionLabel)); - const input = control.querySelector( - `input[type="radio"][value="${MANTINE_BOOLEAN_SEGMENT_VALUE[optionLabel]}"]` - ); - if (input instanceof HTMLInputElement && !input.checked) { - input.click(); - } -} - -// Branches dispatch on the 3 UI adapters' distinct boolean-widget DOM shapes. -// fallow-ignore-next-line complexity async function pickBooleanOption( adapter: EventProgramRulesAdapter, canvasElement: HTMLElement, @@ -92,27 +75,24 @@ async function pickBooleanOption( const canvas = canvasOf(canvasElement); if (adapter === 'dhis2-ui') { - const field = Array.from( - canvasElement.querySelectorAll('[data-test="dhis2-uiwidgets-singleselectfield"]') - ).find((element) => fieldLabel.test(element.textContent ?? '')); + const field = Array.from(canvasElement.querySelectorAll('fieldset')).find((element) => + fieldLabel.test(element.querySelector('legend')?.textContent?.trim() ?? '') + ); if (!field) { throw new Error(`DHIS2 boolean field matching ${fieldLabel} not found`); } - const trigger = field.querySelector('[data-test="dhis2-uicore-select-input"]'); - if (!trigger) throw new Error('DHIS2 single select trigger not found'); - await userEvent.click(trigger); - await userEvent.click(await screen.findByText(optionLabel)); + await userEvent.click(within(field).getByRole('radio', { name: optionLabel })); return; } if (adapter === 'mantine') { - const control = canvas.getByLabelText(fieldLabel); - await clickMantineBooleanSegment(control, optionLabel); + const control = canvas.getByRole('radiogroup', { name: fieldLabel }); + await userEvent.click(within(control).getByRole('radio', { name: optionLabel })); return; } const group = canvas.getByRole('group', { name: fieldLabel }); - await userEvent.click(within(group).getByRole('button', { name: optionLabel })); + await userEvent.click(within(group).getByRole('radio', { name: optionLabel })); } function isColourMenuOpen(adapter: EventProgramRulesAdapter): boolean { diff --git a/apps/storybook/interactions/fieldStoryInteractions.ts b/apps/storybook/interactions/fieldStoryInteractions.ts index 4e3dbc1..5c5c6f2 100644 --- a/apps/storybook/interactions/fieldStoryInteractions.ts +++ b/apps/storybook/interactions/fieldStoryInteractions.ts @@ -27,16 +27,39 @@ function queryNumericInput(canvas: Canvas, widgetKind: WidgetKind) { } } +const NUMERIC_WIDGET_KINDS = new Set(['integer', 'number', 'percentage']); +const TEMPORAL_WIDGET_KINDS = new Set(['date', 'time', 'datetime', 'age']); + +// fallow-ignore-next-line complexity +function queryTemporalInput(canvas: Canvas, widgetKind: WidgetKind) { + const labeled = canvas.queryAllByLabelText(labelPattern(widgetKind)); + if (labeled[0]) return labeled[0]; + + for (const role of ['textbox', 'combobox', 'group'] as const) { + const matches = canvas.queryAllByRole(role, { name: labelPattern(widgetKind) }); + if (matches[0]) return matches[0]; + } + + // MUI X pickers sometimes expose the visible label as plain text only. + const byText = canvas.queryByText(labelPattern(widgetKind)); + if (!byText) { + throw new Error(`Unable to locate input for widgetKind=${widgetKind}`); + } + + const container = byText.closest('.MuiFormControl-root') ?? byText.parentElement; + const input = container?.querySelector('input'); + if (!input) { + throw new Error(`Unable to locate input for widgetKind=${widgetKind}`); + } + return input; +} + function queryFieldInput(canvas: Canvas, widgetKind: WidgetKind) { - if (widgetKind === 'integer' || widgetKind === 'number' || widgetKind === 'percentage') { + if (NUMERIC_WIDGET_KINDS.has(widgetKind)) { return queryNumericInput(canvas, widgetKind); } - if (widgetKind === 'date' || widgetKind === 'time' || widgetKind === 'age') { - try { - return canvas.getByLabelText(labelPattern(widgetKind)); - } catch { - return queryTextInput(canvas, widgetKind); - } + if (TEMPORAL_WIDGET_KINDS.has(widgetKind)) { + return queryTemporalInput(canvas, widgetKind); } return queryTextInput(canvas, widgetKind); } @@ -72,6 +95,13 @@ async function openDhis2SingleSelect(canvasElement: HTMLElement) { await userEvent.click(trigger); } +async function openDhis2MultiSelect(canvasElement: HTMLElement) { + const field = getByDataTest(canvasElement, 'dhis2-uiwidgets-multiselectfield'); + const trigger = field.querySelector('[data-test="dhis2-uicore-select-input"]'); + if (!trigger) throw new Error('DHIS2 multi select trigger not found'); + await userEvent.click(trigger); +} + async function pickSelectOption( adapter: FieldStoryAdapter, canvasElement: HTMLElement, @@ -97,35 +127,45 @@ async function pickSelectOption( await userEvent.click(await screen.findByRole('option', { name: optionLabel })); } -async function clickMantineBooleanSegment(control: HTMLElement, optionLabel: 'Yes' | 'No') { - await userEvent.click(within(control).getByText(optionLabel)); - const value = optionLabel === 'Yes' ? 'true' : 'false'; - const input = control.querySelector(`input[type="radio"][value="${value}"]`); - if (input instanceof HTMLInputElement && !input.checked) { - input.click(); - } -} - -async function pickBooleanYes( +// fallow-ignore-next-line complexity +async function pickMultiSelectOption( adapter: FieldStoryAdapter, canvasElement: HTMLElement, - widgetKind: WidgetKind = 'boolean' + widgetKind: WidgetKind, + optionLabel: string ) { const canvas = canvasOf(canvasElement); if (adapter === 'dhis2-ui') { - await openDhis2SingleSelect(canvasElement); - await userEvent.click(await screen.findByText('Yes')); + await openDhis2MultiSelect(canvasElement); + const option = await screen.findByText(optionLabel); + await userEvent.click(option); + await expect( + getByDataTest(canvasElement, 'dhis2-uiwidgets-multiselectfield') + ).toBeInTheDocument(); return; } if (adapter === 'mantine') { - const control = canvas.getByLabelText(labelPattern(widgetKind)); - await clickMantineBooleanSegment(control, 'Yes'); + const input = canvas.getByRole('textbox', { name: labelPattern(widgetKind) }); + const target = input.closest('.mantine-MultiSelect-input') ?? input.parentElement ?? input; + await userEvent.click(target); + await expect(await screen.findByRole('option', { name: optionLabel })).toBeInTheDocument(); + await userEvent.click(screen.getByRole('option', { name: optionLabel })); return; } - await userEvent.click(canvas.getByRole('button', { name: 'Yes' })); + await userEvent.click(canvas.getByRole('combobox', { name: labelPattern(widgetKind) })); + await userEvent.click(await screen.findByRole('option', { name: optionLabel })); +} + +async function pickBooleanYes( + _adapter: FieldStoryAdapter, + canvasElement: HTMLElement, + _widgetKind: WidgetKind = 'boolean' +) { + const canvas = canvasOf(canvasElement); + await userEvent.click(canvas.getByRole('radio', { name: 'Yes' })); } export function fieldStoryPlays(adapter: FieldStoryAdapter) { @@ -196,12 +236,40 @@ export function fieldStoryPlays(adapter: FieldStoryAdapter) { await pickSelectOption(adapter, canvasElement, 'select', 'Option A'); }; + const multiSelectOption: PlayFunction = async ({ canvasElement }) => { + await pickMultiSelectOption(adapter, canvasElement, 'multiSelect', 'Option A'); + }; + const booleanYes: PlayFunction = async ({ canvasElement, args }) => { await pickBooleanYes(adapter, canvasElement, args?.widgetKind ?? 'boolean'); }; const dateInput: PlayFunction = async ({ canvasElement }) => { - await typeIntoField(canvasElement, 'date', '2024-06-15'); + const canvas = canvasOf(canvasElement); + const input = queryFieldInput(canvas, 'date'); + await expect(input).toBeInTheDocument(); + if (input instanceof HTMLInputElement && !input.readOnly) { + await userEvent.clear(input); + await userEvent.type(input, '2024-06-15'); + await assertInputValue(input, '2024-06-15'); + } + }; + + const timeInput: PlayFunction = async ({ canvasElement }) => { + const canvas = canvasOf(canvasElement); + const input = queryFieldInput(canvas, 'time'); + await expect(input).toBeInTheDocument(); + if (input instanceof HTMLInputElement && !input.readOnly) { + await userEvent.clear(input); + await userEvent.type(input, '14:30'); + await assertInputValue(input, '14:30'); + } + }; + + const datetimeInput: PlayFunction = async ({ canvasElement }) => { + const canvas = canvasOf(canvasElement); + const matches = canvas.queryAllByLabelText(labelPattern('datetime')); + await expect(matches.length).toBeGreaterThan(0); }; const ageShowsComputedAge: PlayFunction = async ({ canvasElement }) => { @@ -228,8 +296,11 @@ export function fieldStoryPlays(adapter: FieldStoryAdapter) { generatedFieldDisabled, integerInput, selectOption, + multiSelectOption, booleanYes, dateInput, + timeInput, + datetimeInput, ageShowsComputedAge, stubWidget, }; diff --git a/apps/storybook/interactions/trackerProgramRulesInteractions.ts b/apps/storybook/interactions/trackerProgramRulesInteractions.ts index 008a47f..f2c5af0 100644 --- a/apps/storybook/interactions/trackerProgramRulesInteractions.ts +++ b/apps/storybook/interactions/trackerProgramRulesInteractions.ts @@ -65,23 +65,6 @@ async function typeAge(canvasElement: HTMLElement, value: string) { await userEvent.type(input, value); } -const MANTINE_BOOLEAN_SEGMENT_VALUE: Record<'Yes' | 'No', string> = { - Yes: 'true', - No: 'false', -}; - -async function clickMantineBooleanSegment(control: HTMLElement, optionLabel: 'Yes' | 'No') { - await userEvent.click(within(control).getByText(optionLabel)); - const input = control.querySelector( - `input[type="radio"][value="${MANTINE_BOOLEAN_SEGMENT_VALUE[optionLabel]}"]` - ); - if (input instanceof HTMLInputElement && !input.checked) { - input.click(); - } -} - -// Branches dispatch on the 3 UI adapters' distinct boolean-widget DOM shapes. -// fallow-ignore-next-line complexity async function pickBooleanOption( adapter: TrackerProgramRulesAdapter, canvasElement: HTMLElement, @@ -91,27 +74,24 @@ async function pickBooleanOption( const canvas = canvasOf(canvasElement); if (adapter === 'dhis2-ui') { - const field = Array.from( - canvasElement.querySelectorAll('[data-test="dhis2-uiwidgets-singleselectfield"]') - ).find((element) => fieldLabel.test(element.textContent ?? '')); + const field = Array.from(canvasElement.querySelectorAll('fieldset')).find((element) => + fieldLabel.test(element.querySelector('legend')?.textContent?.trim() ?? '') + ); if (!field) { throw new Error(`DHIS2 boolean field matching ${fieldLabel} not found`); } - const trigger = field.querySelector('[data-test="dhis2-uicore-select-input"]'); - if (!trigger) throw new Error('DHIS2 single select trigger not found'); - await userEvent.click(trigger); - await userEvent.click(await within(document.body).findByText(optionLabel)); + await userEvent.click(within(field).getByRole('radio', { name: optionLabel })); return; } if (adapter === 'mantine') { - const control = canvas.getByLabelText(fieldLabel); - await clickMantineBooleanSegment(control, optionLabel); + const control = canvas.getByRole('radiogroup', { name: fieldLabel }); + await userEvent.click(within(control).getByRole('radio', { name: optionLabel })); return; } const group = canvas.getByRole('group', { name: fieldLabel }); - await userEvent.click(within(group).getByRole('button', { name: optionLabel })); + await userEvent.click(within(group).getByRole('radio', { name: optionLabel })); } export function trackerProgramRulesPlays(adapter: TrackerProgramRulesAdapter) { diff --git a/apps/storybook/package.json b/apps/storybook/package.json index 065473b..434534f 100644 --- a/apps/storybook/package.json +++ b/apps/storybook/package.json @@ -20,8 +20,11 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@mantine/core": "^7.17.8", + "@mantine/dates": "^7.17.8", "@mantine/hooks": "^7.17.8", "@mui/material": "^9.0.1", + "@mui/x-date-pickers": "^9.10.1", + "dayjs": "^1.11.21", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.76.0" @@ -37,14 +40,14 @@ "@types/react-dom": "^18.3.7", "@vitest/browser": "^4.1.10", "@vitest/browser-playwright": "^4.1.7", + "eslint-plugin-storybook": "10.4.6", "msw": "^2.14.6", "msw-storybook-addon": "^2.0.7", "playwright": "^1.60.0", "storybook": "^10.4.6", "typescript": "^5.9.3", "vite": "^8.1.0", - "vitest": "^4.1.7", - "eslint-plugin-storybook": "10.4.6" + "vitest": "^4.1.7" }, "msw": { "workerDirectory": [ diff --git a/apps/storybook/stories/dhis2-ui/D2Field.stories.tsx b/apps/storybook/stories/dhis2-ui/D2Field.stories.tsx index 3c81039..76cbb98 100644 --- a/apps/storybook/stories/dhis2-ui/D2Field.stories.tsx +++ b/apps/storybook/stories/dhis2-ui/D2Field.stories.tsx @@ -94,6 +94,11 @@ export const Select: Story = { play: plays.selectOption, }; +export const MultiSelect: Story = { + args: { widgetKind: 'multiSelect' }, + play: plays.multiSelectOption, +}; + export const Boolean: Story = { args: { widgetKind: 'boolean' }, play: plays.booleanYes, @@ -104,12 +109,22 @@ export const Date: Story = { play: plays.dateInput, }; +export const Time: Story = { + args: { widgetKind: 'time' }, + play: plays.timeInput, +}; + +export const DateTime: Story = { + args: { widgetKind: 'datetime' }, + play: plays.datetimeInput, +}; + export const Age: Story = { args: { widgetKind: 'age', defaultValue: '2000-01-01' }, play: plays.ageShowsComputedAge, }; export const StubWidget: Story = { - args: { widgetKind: 'datetime' }, - play: plays.stubWidget('datetime'), + args: { widgetKind: 'orgUnit' }, + play: plays.stubWidget('orgUnit'), }; diff --git a/apps/storybook/stories/mantine/D2Field.stories.tsx b/apps/storybook/stories/mantine/D2Field.stories.tsx index f2ef855..d90a80f 100644 --- a/apps/storybook/stories/mantine/D2Field.stories.tsx +++ b/apps/storybook/stories/mantine/D2Field.stories.tsx @@ -69,6 +69,11 @@ export const Select: Story = { play: plays.selectOption, }; +export const MultiSelect: Story = { + args: { widgetKind: 'multiSelect' }, + play: plays.multiSelectOption, +}; + export const Boolean: Story = { args: { widgetKind: 'boolean' }, play: plays.booleanYes, @@ -79,12 +84,22 @@ export const Date: Story = { play: plays.dateInput, }; +export const Time: Story = { + args: { widgetKind: 'time' }, + play: plays.timeInput, +}; + +export const DateTime: Story = { + args: { widgetKind: 'datetime' }, + play: plays.datetimeInput, +}; + export const Age: Story = { args: { widgetKind: 'age', defaultValue: '2000-01-01' }, play: plays.ageShowsComputedAge, }; export const StubWidget: Story = { - args: { widgetKind: 'datetime' }, - play: plays.stubWidget('datetime'), + args: { widgetKind: 'orgUnit' }, + play: plays.stubWidget('orgUnit'), }; diff --git a/apps/storybook/stories/mui/D2Field.stories.tsx b/apps/storybook/stories/mui/D2Field.stories.tsx index b32b08d..22a4949 100644 --- a/apps/storybook/stories/mui/D2Field.stories.tsx +++ b/apps/storybook/stories/mui/D2Field.stories.tsx @@ -61,6 +61,11 @@ export const Select: Story = { play: plays.selectOption, }; +export const MultiSelect: Story = { + args: { widgetKind: 'multiSelect' }, + play: plays.multiSelectOption, +}; + export const Boolean: Story = { args: { widgetKind: 'boolean' }, play: plays.booleanYes, @@ -71,6 +76,16 @@ export const Date: Story = { play: plays.dateInput, }; +export const Time: Story = { + args: { widgetKind: 'time' }, + play: plays.timeInput, +}; + +export const DateTime: Story = { + args: { widgetKind: 'datetime' }, + play: plays.datetimeInput, +}; + export const Age: Story = { args: { widgetKind: 'age', defaultValue: '2000-01-01' }, play: plays.ageShowsComputedAge, diff --git a/components/dhis2-ui/src/fields/D2Field.tsx b/components/dhis2-ui/src/fields/D2Field.tsx index c3b4566..38e6302 100644 --- a/components/dhis2-ui/src/fields/D2Field.tsx +++ b/components/dhis2-ui/src/fields/D2Field.tsx @@ -1,11 +1,16 @@ +// fallow-ignore-file code-duplication import { type FieldControlInput, useFieldControl, type WidgetProps } from '@dhis2-form-utils/hooks'; +import type { WidgetKind } from '@dhis2-form-utils/hooks'; +import type { ComponentType } from 'react'; import { D2AgeField, D2BooleanField, D2DateField, + D2DateTimeField, D2EmailField, D2IntegerField, D2LongTextField, + D2MultiSelectField, D2NumberField, D2PercentageField, D2PhoneField, @@ -22,43 +27,33 @@ export type D2FieldProps = { export type D2FieldWidgetProps = WidgetProps; +const WIDGET_BY_KIND: Record> = { + text: D2TextField, + longText: D2LongTextField, + email: D2EmailField, + phone: D2PhoneField, + number: D2NumberField, + integer: D2IntegerField, + percentage: D2PercentageField, + boolean: D2BooleanField, + trueOnly: D2TrueOnlyField, + date: D2DateField, + time: D2TimeField, + age: D2AgeField, + select: D2SelectField, + multiSelect: D2MultiSelectField, + datetime: D2DateTimeField, + coordinate: D2UnsupportedField, + orgUnit: D2UnsupportedField, + file: D2UnsupportedField, + image: D2UnsupportedField, + unsupported: D2UnsupportedField, +}; + /** Dispatches an already-resolved `FieldControlReturn` to its widget component, without re-deriving field control state. Reused by consumers that build their own chrome (labels, badges, ghost placeholders) around a field. */ export function D2FieldWidget({ control }: D2FieldWidgetProps) { - switch (control.widgetKind) { - case 'text': - return ; - case 'longText': - return ; - case 'email': - return ; - case 'phone': - return ; - case 'number': - return ; - case 'integer': - return ; - case 'percentage': - return ; - case 'boolean': - return ; - case 'trueOnly': - return ; - case 'date': - return ; - case 'time': - return ; - case 'age': - return ; - case 'select': - return ; - case 'datetime': - case 'coordinate': - case 'orgUnit': - case 'file': - case 'image': - case 'unsupported': - return ; - } + const Widget = WIDGET_BY_KIND[control.widgetKind]; + return ; } export function D2Field({ field }: D2FieldProps) { diff --git a/components/dhis2-ui/src/fields/widgets/ChoiceFields.tsx b/components/dhis2-ui/src/fields/widgets/ChoiceFields.tsx index 19fe0e6..c24371b 100644 --- a/components/dhis2-ui/src/fields/widgets/ChoiceFields.tsx +++ b/components/dhis2-ui/src/fields/widgets/ChoiceFields.tsx @@ -1,38 +1,60 @@ -import { CheckboxField, Radio, SingleSelectField, SingleSelectOption } from '@dhis2/ui'; +import { + CheckboxField, + MultiSelectField, + MultiSelectOption, + Radio, + SingleSelectField, + SingleSelectOption, +} from '@dhis2/ui'; import type { WidgetProps } from '@dhis2-form-utils/hooks'; -import { resolveFieldValidation } from '@dhis2-form-utils/hooks'; +import { + joinMultiTextValue, + parseMultiTextValue, + resolveFieldValidation, +} from '@dhis2-form-utils/hooks'; const BOOLEAN_OPTIONS = [ { label: 'Yes', value: 'true' }, { label: 'No', value: 'false' }, - { label: '—', value: '' }, -]; +] as const; const RADIO_RENDER_HINTS = new Set(['RADIO', 'VERTICAL_RADIOBUTTONS', 'HORIZONTAL_RADIOBUTTONS']); +// fallow-ignore-next-line complexity export function D2BooleanField({ control }: WidgetProps) { const { fieldConfig, field, isMandatory, isDisabled } = control; const { validationText, hasError, hasWarning } = resolveFieldValidation(control); + const options = isMandatory + ? [...BOOLEAN_OPTIONS] + : [...BOOLEAN_OPTIONS, { label: '—', value: '' }]; return ( - { - field.onChange(selected); - }} - onBlur={field.onBlur} - > - {BOOLEAN_OPTIONS.map((option) => ( - +
+ + {fieldConfig.label} + {isMandatory ? ' *' : ''} + + {fieldConfig.description ?

{fieldConfig.description}

: null} + {options.map((option) => ( + { + field.onChange(value ?? ''); + }} + onBlur={field.onBlur} + /> ))} - + {validationText ? ( +

+ {validationText} +

+ ) : null} +
); } @@ -111,3 +133,36 @@ export function D2SelectField({ control }: WidgetProps) {
); } + +export function D2MultiSelectField({ control }: WidgetProps) { + const { fieldConfig, field, isMandatory, isDisabled } = control; + const { validationText, hasError, hasWarning } = resolveFieldValidation(control); + const options = (control.visibleOptions ?? fieldConfig.optionSet?.options ?? []).map( + (option) => ({ + label: option.label, + value: option.code, + }) + ); + const selected = parseMultiTextValue(field.value as string); + + return ( + { + field.onChange(joinMultiTextValue(nextSelected)); + }} + onBlur={field.onBlur} + > + {options.map((option) => ( + + ))} + + ); +} diff --git a/components/dhis2-ui/src/fields/widgets/DateFields.tsx b/components/dhis2-ui/src/fields/widgets/DateFields.tsx index d8a6ec8..e3d8168 100644 --- a/components/dhis2-ui/src/fields/widgets/DateFields.tsx +++ b/components/dhis2-ui/src/fields/widgets/DateFields.tsx @@ -1,10 +1,55 @@ -import { InputField } from '@dhis2/ui'; +import { CalendarInput, InputField } from '@dhis2/ui'; import type { WidgetProps } from '@dhis2-form-utils/hooks'; -import { resolveFieldValidation } from '@dhis2-form-utils/hooks'; +import { computeAgeFromDob, resolveFieldValidation } from '@dhis2-form-utils/hooks'; + +const todayIso = (): string => new Date().toISOString().slice(0, 10); + +type DateSelectPayload = { + calendarDateString?: string; +} | null; + +const readDatePart = (value: string): string => { + if (!value) return ''; + return value.includes('T') ? value.slice(0, 10) : value; +}; + +const readTimePart = (value: string): string => { + if (!value.includes('T')) return ''; + return value.slice(11, 16); +}; export function D2DateField({ control }: WidgetProps) { const { fieldConfig, field, isMandatory, isDisabled } = control; const { validationText, hasError, hasWarning } = resolveFieldValidation(control); + const dateValue = field.value as string; + + return ( + { + field.onChange(payload?.calendarDateString ?? ''); + }} + onBlur={field.onBlur} + /> + ); +} + +export function D2TimeField({ control }: WidgetProps) { + const { fieldConfig, field, isMandatory, isDisabled } = control; + const { validationText, hasError, hasWarning } = resolveFieldValidation(control); return ( = 0 ? String(age) : ''; +export function D2DateTimeField({ control }: WidgetProps) { + const { fieldConfig, field, isMandatory, isDisabled } = control; + const { validationText, hasError, hasWarning } = resolveFieldValidation(control); + const value = field.value as string; + const datePart = readDatePart(value); + const timePart = readTimePart(value); + + const commit = (nextDate: string, nextTime: string) => { + if (!nextDate && !nextTime) { + field.onChange(''); + return; + } + field.onChange(`${nextDate}T${nextTime || '00:00'}`); + }; + + return ( +
+ { + commit(payload?.calendarDateString ?? '', timePart); + }} + onBlur={field.onBlur} + /> + { + commit(datePart, nextTime ?? ''); + }} + onBlur={field.onBlur} + /> +
+ ); } export function D2AgeField({ control }: WidgetProps) { diff --git a/components/dhis2-ui/src/fields/widgets/NumericFields.tsx b/components/dhis2-ui/src/fields/widgets/NumericFields.tsx index df292c9..1ae5d15 100644 --- a/components/dhis2-ui/src/fields/widgets/NumericFields.tsx +++ b/components/dhis2-ui/src/fields/widgets/NumericFields.tsx @@ -3,10 +3,7 @@ import { InputField } from '@dhis2/ui'; import { resolveFieldValidation } from '@dhis2-form-utils/hooks'; import { D2TextField } from './TextField'; -function D2TypedTextField({ - control, - type, -}: WidgetProps & { type?: 'email' | 'tel' | 'number' | 'time' | 'date' }) { +function D2TypedTextField({ control, type }: WidgetProps & { type?: 'email' | 'tel' | 'number' }) { const { fieldConfig, field, isMandatory, isDisabled } = control; const { validationText, hasError, hasWarning } = resolveFieldValidation(control); @@ -59,7 +56,3 @@ export function D2PercentageField({ control }: WidgetProps) { /> ); } - -export function D2TimeField(props: WidgetProps) { - return ; -} diff --git a/components/dhis2-ui/src/fields/widgets/index.ts b/components/dhis2-ui/src/fields/widgets/index.ts index ed21ca8..9ca3e4d 100644 --- a/components/dhis2-ui/src/fields/widgets/index.ts +++ b/components/dhis2-ui/src/fields/widgets/index.ts @@ -6,8 +6,7 @@ export { D2NumberField, D2IntegerField, D2PercentageField, - D2TimeField, } from './NumericFields'; -export { D2DateField, D2AgeField } from './DateFields'; -export { D2BooleanField, D2TrueOnlyField, D2SelectField } from './ChoiceFields'; +export { D2DateField, D2AgeField, D2TimeField, D2DateTimeField } from './DateFields'; +export { D2BooleanField, D2TrueOnlyField, D2SelectField, D2MultiSelectField } from './ChoiceFields'; export { D2UnsupportedField } from './UnsupportedField'; diff --git a/components/mantine/package.json b/components/mantine/package.json index 214a862..6f54ee8 100644 --- a/components/mantine/package.json +++ b/components/mantine/package.json @@ -27,7 +27,9 @@ }, "peerDependencies": { "@mantine/core": ">=7.0.0", + "@mantine/dates": ">=7.0.0", "@mantine/hooks": ">=7.0.0", + "dayjs": ">=1.11.0", "react": ">=18.0.0", "react-dom": ">=18.0.0", "react-hook-form": ">=7.0.0" @@ -35,9 +37,11 @@ "devDependencies": { "@dhis2-form-utils/config": "workspace:*", "@mantine/core": "^7.17.8", + "@mantine/dates": "^7.17.8", "@mantine/hooks": "^7.17.8", "@types/react": "^18.3.29", "@types/react-dom": "^18.3.7", + "dayjs": "^1.11.21", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.76.0", diff --git a/components/mantine/src/fields/D2Field.tsx b/components/mantine/src/fields/D2Field.tsx index f950ea8..ba07928 100644 --- a/components/mantine/src/fields/D2Field.tsx +++ b/components/mantine/src/fields/D2Field.tsx @@ -1,11 +1,20 @@ -import { type FieldControlInput, useFieldControl } from '@dhis2-form-utils/hooks'; +// fallow-ignore-file code-duplication +import { + type FieldControlInput, + useFieldControl, + type WidgetKind, + type WidgetProps, +} from '@dhis2-form-utils/hooks'; +import type { ComponentType } from 'react'; import { D2AgeField, D2BooleanField, D2DateField, + D2DateTimeField, D2EmailField, D2IntegerField, D2LongTextField, + D2MultiSelectField, D2NumberField, D2PercentageField, D2PhoneField, @@ -20,44 +29,34 @@ export type D2FieldProps = { field: FieldControlInput; }; +const WIDGET_BY_KIND: Record> = { + text: D2TextField, + longText: D2LongTextField, + email: D2EmailField, + phone: D2PhoneField, + number: D2NumberField, + integer: D2IntegerField, + percentage: D2PercentageField, + boolean: D2BooleanField, + trueOnly: D2TrueOnlyField, + date: D2DateField, + time: D2TimeField, + age: D2AgeField, + select: D2SelectField, + multiSelect: D2MultiSelectField, + datetime: D2DateTimeField, + coordinate: D2UnsupportedField, + orgUnit: D2UnsupportedField, + file: D2UnsupportedField, + image: D2UnsupportedField, + unsupported: D2UnsupportedField, +}; + export function D2Field({ field }: D2FieldProps) { const fieldControl = useFieldControl({ ...field }); if (fieldControl.isHidden) return null; - switch (fieldControl.widgetKind) { - case 'text': - return ; - case 'longText': - return ; - case 'email': - return ; - case 'phone': - return ; - case 'number': - return ; - case 'integer': - return ; - case 'percentage': - return ; - case 'boolean': - return ; - case 'trueOnly': - return ; - case 'date': - return ; - case 'time': - return ; - case 'age': - return ; - case 'select': - return ; - case 'datetime': - case 'coordinate': - case 'orgUnit': - case 'file': - case 'image': - case 'unsupported': - return ; - } + const Widget = WIDGET_BY_KIND[fieldControl.widgetKind]; + return ; } diff --git a/components/mantine/src/fields/widgets/ChoiceFields.tsx b/components/mantine/src/fields/widgets/ChoiceFields.tsx index 109242c..2ac52a2 100644 --- a/components/mantine/src/fields/widgets/ChoiceFields.tsx +++ b/components/mantine/src/fields/widgets/ChoiceFields.tsx @@ -1,31 +1,49 @@ -import { Checkbox, Radio, SegmentedControl, Select } from '@mantine/core'; +import { Checkbox, MultiSelect, Radio, Select } from '@mantine/core'; import type { WidgetProps } from '@dhis2-form-utils/hooks'; -import { resolveFieldValidation } from '@dhis2-form-utils/hooks'; +import { + joinMultiTextValue, + parseMultiTextValue, + resolveFieldValidation, +} from '@dhis2-form-utils/hooks'; const RADIO_RENDER_HINTS = new Set(['RADIO', 'VERTICAL_RADIOBUTTONS', 'HORIZONTAL_RADIOBUTTONS']); export function D2BooleanField({ control }: WidgetProps) { const { fieldConfig, field, isMandatory, isDisabled } = control; const { validationText, hasError } = resolveFieldValidation(control); + const options = isMandatory + ? [ + { label: 'Yes', value: 'true' }, + { label: 'No', value: 'false' }, + ] + : [ + { label: 'Yes', value: 'true' }, + { label: 'No', value: 'false' }, + { label: '—', value: '' }, + ]; return ( - { field.onChange(value); }} onBlur={field.onBlur} - aria-label={fieldConfig.label} - {...(hasError ? { 'data-error': validationText } : {})} - {...(isMandatory ? { 'data-required': true } : {})} - /> + error={hasError ? validationText : undefined} + > + {options.map((option) => ( + + ))} + ); } @@ -50,6 +68,7 @@ export function D2TrueOnlyField({ control }: WidgetProps) { ); } +// fallow-ignore-next-line complexity export function D2SelectField({ control }: WidgetProps) { const { fieldConfig, field, isMandatory, isDisabled } = control; const { validationText, hasError } = resolveFieldValidation(control); @@ -103,3 +122,32 @@ export function D2SelectField({ control }: WidgetProps) { /> ); } + +// fallow-ignore-next-line complexity +export function D2MultiSelectField({ control }: WidgetProps) { + const { fieldConfig, field, isMandatory, isDisabled } = control; + const { validationText, hasError } = resolveFieldValidation(control); + const options = (control.visibleOptions ?? fieldConfig.optionSet?.options ?? []).map( + (option) => ({ + label: option.label, + value: option.code, + }) + ); + + return ( + { + field.onChange(joinMultiTextValue(value)); + }} + onBlur={field.onBlur} + /> + ); +} diff --git a/components/mantine/src/fields/widgets/DateFields.tsx b/components/mantine/src/fields/widgets/DateFields.tsx new file mode 100644 index 0000000..eaa86fe --- /dev/null +++ b/components/mantine/src/fields/widgets/DateFields.tsx @@ -0,0 +1,105 @@ +import { DateInput, DateTimePicker, TimeInput } from '@mantine/dates'; +import dayjs from 'dayjs'; +import type { WidgetProps } from '@dhis2-form-utils/hooks'; +import { computeAgeFromDob, resolveFieldValidation } from '@dhis2-form-utils/hooks'; + +const today = () => dayjs().endOf('day').toDate(); + +const parseDate = (value: string): Date | null => { + if (!value) return null; + const parsed = dayjs(value); + return parsed.isValid() ? parsed.toDate() : null; +}; + +const formatDate = (value: Date | null): string => (value ? dayjs(value).format('YYYY-MM-DD') : ''); + +const formatDateTime = (value: Date | null): string => + value ? dayjs(value).format('YYYY-MM-DDTHH:mm') : ''; + +export function D2DateField({ control }: WidgetProps) { + const { fieldConfig, field, isMandatory, isDisabled } = control; + const { validationText, hasError } = resolveFieldValidation(control); + + return ( + { + field.onChange(formatDate(value)); + }} + onBlur={field.onBlur} + /> + ); +} + +export function D2TimeField({ control }: WidgetProps) { + const { fieldConfig, field, isMandatory, isDisabled } = control; + const { validationText, hasError } = resolveFieldValidation(control); + + return ( + { + field.onChange(event.currentTarget.value); + }} + onBlur={field.onBlur} + /> + ); +} + +export function D2DateTimeField({ control }: WidgetProps) { + const { fieldConfig, field, isMandatory, isDisabled } = control; + const { validationText, hasError } = resolveFieldValidation(control); + + return ( + { + field.onChange(formatDateTime(value)); + }} + onBlur={field.onBlur} + /> + ); +} + +export function D2AgeField({ control }: WidgetProps) { + const age = computeAgeFromDob(control.field.value as string); + const description = [control.fieldConfig.description, age ? `Age: ${age} years` : undefined] + .filter(Boolean) + .join(' · '); + + return ( + + ); +} diff --git a/components/mantine/src/fields/widgets/TextField.tsx b/components/mantine/src/fields/widgets/TextField.tsx index f295e00..0a59428 100644 --- a/components/mantine/src/fields/widgets/TextField.tsx +++ b/components/mantine/src/fields/widgets/TextField.tsx @@ -56,42 +56,3 @@ export function D2EmailField(props: WidgetProps) { export function D2PhoneField(props: WidgetProps) { return ; } - -export function D2TimeField(props: WidgetProps) { - return ; -} - -function computeAgeFromDob(dob: string): string { - if (!/^\d{4}-\d{2}-\d{2}$/.test(dob)) return ''; - const birth = new Date(dob); - const today = new Date(); - let age = today.getFullYear() - birth.getFullYear(); - const monthDiff = today.getMonth() - birth.getMonth(); - if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) { - age -= 1; - } - return age >= 0 ? String(age) : ''; -} - -export function D2DateField(props: WidgetProps) { - return ; -} - -export function D2AgeField({ control }: WidgetProps) { - const age = computeAgeFromDob(control.field.value as string); - const description = [control.fieldConfig.description, age ? `Age: ${age} years` : undefined] - .filter(Boolean) - .join(' · '); - - return ( - - ); -} diff --git a/components/mantine/src/fields/widgets/index.ts b/components/mantine/src/fields/widgets/index.ts index 3e0b0f5..6ce680c 100644 --- a/components/mantine/src/fields/widgets/index.ts +++ b/components/mantine/src/fields/widgets/index.ts @@ -1,12 +1,5 @@ -export { - D2TextField, - D2LongTextField, - D2EmailField, - D2PhoneField, - D2TimeField, - D2DateField, - D2AgeField, -} from './TextField'; +export { D2TextField, D2LongTextField, D2EmailField, D2PhoneField } from './TextField'; export { D2NumberField, D2IntegerField, D2PercentageField } from './NumericFields'; -export { D2BooleanField, D2TrueOnlyField, D2SelectField } from './ChoiceFields'; +export { D2DateField, D2AgeField, D2TimeField, D2DateTimeField } from './DateFields'; +export { D2BooleanField, D2TrueOnlyField, D2SelectField, D2MultiSelectField } from './ChoiceFields'; export { D2UnsupportedField } from './UnsupportedField'; diff --git a/components/mui/package.json b/components/mui/package.json index 738f3b3..cd52261 100644 --- a/components/mui/package.json +++ b/components/mui/package.json @@ -29,6 +29,8 @@ "@emotion/react": ">=11.0.0", "@emotion/styled": ">=11.0.0", "@mui/material": ">=6.0.0", + "@mui/x-date-pickers": ">=7.0.0", + "dayjs": ">=1.11.0", "react": ">=18.0.0", "react-dom": ">=18.0.0", "react-hook-form": ">=7.0.0" @@ -38,8 +40,10 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@mui/material": "^9.0.1", + "@mui/x-date-pickers": "^9.10.1", "@types/react": "^18.3.29", "@types/react-dom": "^18.3.7", + "dayjs": "^1.11.21", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.76.0", diff --git a/components/mui/src/fields/D2Field.tsx b/components/mui/src/fields/D2Field.tsx index f950ea8..ba07928 100644 --- a/components/mui/src/fields/D2Field.tsx +++ b/components/mui/src/fields/D2Field.tsx @@ -1,11 +1,20 @@ -import { type FieldControlInput, useFieldControl } from '@dhis2-form-utils/hooks'; +// fallow-ignore-file code-duplication +import { + type FieldControlInput, + useFieldControl, + type WidgetKind, + type WidgetProps, +} from '@dhis2-form-utils/hooks'; +import type { ComponentType } from 'react'; import { D2AgeField, D2BooleanField, D2DateField, + D2DateTimeField, D2EmailField, D2IntegerField, D2LongTextField, + D2MultiSelectField, D2NumberField, D2PercentageField, D2PhoneField, @@ -20,44 +29,34 @@ export type D2FieldProps = { field: FieldControlInput; }; +const WIDGET_BY_KIND: Record> = { + text: D2TextField, + longText: D2LongTextField, + email: D2EmailField, + phone: D2PhoneField, + number: D2NumberField, + integer: D2IntegerField, + percentage: D2PercentageField, + boolean: D2BooleanField, + trueOnly: D2TrueOnlyField, + date: D2DateField, + time: D2TimeField, + age: D2AgeField, + select: D2SelectField, + multiSelect: D2MultiSelectField, + datetime: D2DateTimeField, + coordinate: D2UnsupportedField, + orgUnit: D2UnsupportedField, + file: D2UnsupportedField, + image: D2UnsupportedField, + unsupported: D2UnsupportedField, +}; + export function D2Field({ field }: D2FieldProps) { const fieldControl = useFieldControl({ ...field }); if (fieldControl.isHidden) return null; - switch (fieldControl.widgetKind) { - case 'text': - return ; - case 'longText': - return ; - case 'email': - return ; - case 'phone': - return ; - case 'number': - return ; - case 'integer': - return ; - case 'percentage': - return ; - case 'boolean': - return ; - case 'trueOnly': - return ; - case 'date': - return ; - case 'time': - return ; - case 'age': - return ; - case 'select': - return ; - case 'datetime': - case 'coordinate': - case 'orgUnit': - case 'file': - case 'image': - case 'unsupported': - return ; - } + const Widget = WIDGET_BY_KIND[fieldControl.widgetKind]; + return ; } diff --git a/components/mui/src/fields/widgets/ChoiceFields.tsx b/components/mui/src/fields/widgets/ChoiceFields.tsx index d6d2d87..0912afe 100644 --- a/components/mui/src/fields/widgets/ChoiceFields.tsx +++ b/components/mui/src/fields/widgets/ChoiceFields.tsx @@ -1,32 +1,68 @@ import { Checkbox, + FormControl, FormControlLabel, + FormHelperText, + FormLabel, MenuItem, + Radio, + RadioGroup, TextField, - ToggleButton, - ToggleButtonGroup, } from '@mui/material'; import type { WidgetProps } from '@dhis2-form-utils/hooks'; -import { resolveFieldValidation } from '@dhis2-form-utils/hooks'; +import { + joinMultiTextValue, + parseMultiTextValue, + resolveFieldValidation, +} from '@dhis2-form-utils/hooks'; +// fallow-ignore-next-line complexity export function D2BooleanField({ control }: WidgetProps) { - const { fieldConfig, field, isDisabled } = control; + const { fieldConfig, field, isMandatory, isDisabled } = control; + const { validationText, hasError } = resolveFieldValidation(control); + const options = isMandatory + ? [ + { label: 'Yes', value: 'true' }, + { label: 'No', value: 'false' }, + ] + : [ + { label: 'Yes', value: 'true' }, + { label: 'No', value: 'false' }, + { label: '—', value: '' }, + ]; return ( - { - field.onChange(value ?? ''); - }} - onBlur={field.onBlur} - aria-label={fieldConfig.label} + error={hasError} + margin="normal" > - Yes - No - — - + {fieldConfig.label} + {fieldConfig.description ? ( + {fieldConfig.description} + ) : null} + { + field.onChange(event.target.value); + }} + onBlur={field.onBlur} + row + > + {options.map((option) => ( + } + label={option.label} + /> + ))} + + {hasError && validationText ? {validationText} : null} + ); } @@ -54,7 +90,7 @@ export function D2TrueOnlyField({ control }: WidgetProps) { ); } -// fallow-ignore-next-line complexity -- pre-existing widget, no co-located test coverage yet +// fallow-ignore-next-line complexity export function D2SelectField({ control }: WidgetProps) { const { fieldConfig, field, isMandatory, isDisabled } = control; const { validationText, hasError } = resolveFieldValidation(control); @@ -85,3 +121,43 @@ export function D2SelectField({ control }: WidgetProps) { ); } + +// fallow-ignore-next-line complexity +export function D2MultiSelectField({ control }: WidgetProps) { + const { fieldConfig, field, isMandatory, isDisabled } = control; + const { validationText, hasError } = resolveFieldValidation(control); + const options = control.visibleOptions ?? fieldConfig.optionSet?.options ?? []; + const selected = parseMultiTextValue(field.value as string); + + return ( + { + const next = event.target.value; + const codes = typeof next === 'string' ? parseMultiTextValue(next) : next; + field.onChange(joinMultiTextValue(codes)); + }} + onBlur={field.onBlur} + fullWidth + margin="normal" + slotProps={{ + select: { + multiple: true, + }, + }} + > + {options.map((option) => ( + + {option.label} + + ))} + + ); +} diff --git a/components/mui/src/fields/widgets/DateFields.tsx b/components/mui/src/fields/widgets/DateFields.tsx new file mode 100644 index 0000000..fce15ba --- /dev/null +++ b/components/mui/src/fields/widgets/DateFields.tsx @@ -0,0 +1,141 @@ +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import { DatePicker } from '@mui/x-date-pickers/DatePicker'; +import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker'; +import { TimePicker } from '@mui/x-date-pickers/TimePicker'; +import dayjs, { type Dayjs } from 'dayjs'; +import type { WidgetProps } from '@dhis2-form-utils/hooks'; +import { computeAgeFromDob, resolveFieldValidation } from '@dhis2-form-utils/hooks'; +import type { ReactNode } from 'react'; + +const parseDayjs = (value: string): Dayjs | null => { + if (!value) return null; + const parsed = dayjs(value); + return parsed.isValid() ? parsed : null; +}; + +function withLocalization(children: ReactNode) { + return {children}; +} + +export function D2DateField({ control }: WidgetProps) { + const { fieldConfig, field, isMandatory, isDisabled } = control; + const { validationText, hasError } = resolveFieldValidation(control); + + return withLocalization( + { + field.onChange(value && value.isValid() ? value.format('YYYY-MM-DD') : ''); + }} + slotProps={{ + textField: { + name: field.name, + required: isMandatory, + helperText: hasError ? validationText : fieldConfig.description, + error: hasError, + onBlur: field.onBlur, + fullWidth: true, + margin: 'normal', + }, + field: { + clearable: !isMandatory, + }, + }} + /> + ); +} + +// fallow-ignore-next-line complexity +export function D2TimeField({ control }: WidgetProps) { + const { fieldConfig, field, isMandatory, isDisabled } = control; + const { validationText, hasError } = resolveFieldValidation(control); + const value = field.value as string; + const parsed = value ? dayjs(`1970-01-01T${value}`) : null; + + return withLocalization( + { + field.onChange(next && next.isValid() ? next.format('HH:mm') : ''); + }} + slotProps={{ + textField: { + name: field.name, + required: isMandatory, + helperText: hasError ? validationText : fieldConfig.description, + error: hasError, + onBlur: field.onBlur, + fullWidth: true, + margin: 'normal', + }, + field: { + clearable: !isMandatory, + }, + }} + /> + ); +} + +export function D2DateTimeField({ control }: WidgetProps) { + const { fieldConfig, field, isMandatory, isDisabled } = control; + const { validationText, hasError } = resolveFieldValidation(control); + + return withLocalization( + { + field.onChange(value && value.isValid() ? value.format('YYYY-MM-DDTHH:mm') : ''); + }} + slotProps={{ + textField: { + name: field.name, + required: isMandatory, + helperText: hasError ? validationText : fieldConfig.description, + error: hasError, + onBlur: field.onBlur, + fullWidth: true, + margin: 'normal', + }, + field: { + clearable: !isMandatory, + }, + }} + /> + ); +} + +export function D2AgeField({ control }: WidgetProps) { + const age = computeAgeFromDob(control.field.value as string); + const description = [control.fieldConfig.description, age ? `Age: ${age} years` : undefined] + .filter(Boolean) + .join(' · '); + + return ( + + ); +} diff --git a/components/mui/src/fields/widgets/TextField.tsx b/components/mui/src/fields/widgets/TextField.tsx index e374937..7457d4b 100644 --- a/components/mui/src/fields/widgets/TextField.tsx +++ b/components/mui/src/fields/widgets/TextField.tsx @@ -43,44 +43,3 @@ export function D2EmailField(props: WidgetProps) { export function D2PhoneField(props: WidgetProps) { return ; } - -export function D2TimeField(props: WidgetProps) { - return ; -} - -export function D2DateField(props: WidgetProps) { - return ; -} - -// fallow-ignore-next-line complexity code-duplication -function computeAgeFromDob(dob: string): string { - if (!/^\d{4}-\d{2}-\d{2}$/.test(dob)) return ''; - const birth = new Date(dob); - const today = new Date(); - let age = today.getFullYear() - birth.getFullYear(); - const monthDiff = today.getMonth() - birth.getMonth(); - if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) { - age -= 1; - } - return age >= 0 ? String(age) : ''; -} - -export function D2AgeField({ control }: WidgetProps) { - const age = computeAgeFromDob(control.field.value as string); - const description = [control.fieldConfig.description, age ? `Age: ${age} years` : undefined] - .filter(Boolean) - .join(' · '); - - return ( - - ); -} diff --git a/components/mui/src/fields/widgets/index.ts b/components/mui/src/fields/widgets/index.ts index 3e0b0f5..6ce680c 100644 --- a/components/mui/src/fields/widgets/index.ts +++ b/components/mui/src/fields/widgets/index.ts @@ -1,12 +1,5 @@ -export { - D2TextField, - D2LongTextField, - D2EmailField, - D2PhoneField, - D2TimeField, - D2DateField, - D2AgeField, -} from './TextField'; +export { D2TextField, D2LongTextField, D2EmailField, D2PhoneField } from './TextField'; export { D2NumberField, D2IntegerField, D2PercentageField } from './NumericFields'; -export { D2BooleanField, D2TrueOnlyField, D2SelectField } from './ChoiceFields'; +export { D2DateField, D2AgeField, D2TimeField, D2DateTimeField } from './DateFields'; +export { D2BooleanField, D2TrueOnlyField, D2SelectField, D2MultiSelectField } from './ChoiceFields'; export { D2UnsupportedField } from './UnsupportedField'; diff --git a/docs/use-field-control-plan.md b/docs/use-field-control-plan.md index 19e5da5..fe6efad 100644 --- a/docs/use-field-control-plan.md +++ b/docs/use-field-control-plan.md @@ -828,26 +828,27 @@ export function TextField({ control }: WidgetProps) { ## 7. `valueType` → Widget → Component Map -| `valueType` | `widgetKind` | DHIS2 UI | Mantine | Material UI | -| ----------------------------------- | ------------ | ------------------------------- | -------------------------------- | --------------------------- | -| `TEXT`, `LETTER`, `URL`, `USERNAME` | `text` | `InputField` | `TextInput` | `TextField` | -| `LONG_TEXT` | `longText` | `TextAreaField` | `Textarea` | `TextField multiline` | -| `EMAIL` | `email` | `InputField type=email` | `TextInput type=email` | `TextField type=email` | -| `PHONE_NUMBER` | `phone` | `InputField type=tel` | `TextInput type=tel` | `TextField type=tel` | -| `NUMBER`, `UNIT_INTERVAL` | `number` | `InputField type=number` | `NumberInput` | `TextField type=number` | -| `INTEGER`, `INTEGER_*` | `integer` | `InputField type=number step=1` | `NumberInput allowDecimal=false` | `TextField type=number` | -| `PERCENTAGE` | `percentage` | `InputField` + `%` suffix | `NumberInput` + `%` | `TextField` + `%` adornment | -| `BOOLEAN` | `boolean` | `SingleSelectField` (Yes/No/—) | `SegmentedControl` | `ToggleButtonGroup` | -| `TRUE_ONLY` | `trueOnly` | `Checkbox` | `Checkbox` | `Checkbox` | -| `DATE` | `date` | `CalendarInput` | `DateInput` | `DatePicker` | -| `DATETIME` | `datetime` | custom composite | `DateTimePicker` | `DateTimePicker` | -| `TIME` | `time` | `InputField type=time` | `TimeInput` | `TimePicker` | -| `AGE` | `age` | `CalendarInput` + age display | `DateInput` + age display | `DatePicker` + age display | -| any + `optionSet` | `select` | `SingleSelectField` | `Select` | `Select` | -| `COORDINATE` | `coordinate` | Two `InputField` (lat + lng) | Two `NumberInput` | Two `TextField` | -| `FILE_RESOURCE` | `file` | `FileInputField` | `FileInput` | `Button` file type | -| `IMAGE` | `image` | `FileInputField` + preview | `FileInput` + preview | `Button` file + preview | -| `ORGANISATION_UNIT` | `orgUnit` | DHIS2 `OrgUnitField` | Custom tree picker | Custom tree picker | +| `valueType` | `widgetKind` | DHIS2 UI | Mantine | Material UI | +| ----------------------------------- | ------------- | ----------------------------------- | -------------------------------- | --------------------------- | +| `TEXT`, `LETTER`, `URL`, `USERNAME` | `text` | `InputField` | `TextInput` | `TextField` | +| `LONG_TEXT` | `longText` | `TextAreaField` | `Textarea` | `TextField multiline` | +| `EMAIL` | `email` | `InputField type=email` | `TextInput type=email` | `TextField type=email` | +| `PHONE_NUMBER` | `phone` | `InputField type=tel` | `TextInput type=tel` | `TextField type=tel` | +| `NUMBER`, `UNIT_INTERVAL` | `number` | `InputField type=number` | `NumberInput` | `TextField type=number` | +| `INTEGER`, `INTEGER_*` | `integer` | `InputField type=number step=1` | `NumberInput allowDecimal=false` | `TextField type=number` | +| `PERCENTAGE` | `percentage` | `InputField` + `%` suffix | `NumberInput` + `%` | `TextField` + `%` adornment | +| `BOOLEAN` | `boolean` | Yes/No/(—) `Radio` | Yes/No/(—) `Radio.Group` | Yes/No/(—) `RadioGroup` | +| `TRUE_ONLY` | `trueOnly` | `Checkbox` | `Checkbox` | `Checkbox` | +| `DATE` | `date` | `CalendarInput` | `DateInput` | `DatePicker` | +| `DATETIME` | `datetime` | `CalendarInput` + `InputField` time | `DateTimePicker` | `DateTimePicker` | +| `TIME` | `time` | `InputField type=time` | `TimeInput` | `TimePicker` | +| `AGE` | `age` | `CalendarInput` + age display | `DateInput` + age display | `DatePicker` + age display | +| any + `optionSet` (not MULTI_TEXT) | `select` | `SingleSelectField` | `Select` | `Select` | +| `MULTI_TEXT` + `optionSet` | `multiSelect` | `MultiSelectField` (CSV codes) | `MultiSelect` (CSV codes) | multi `TextField select` | +| `COORDINATE` | `coordinate` | Two `InputField` (lat + lng) | Two `NumberInput` | Two `TextField` | +| `FILE_RESOURCE` | `file` | `FileInputField` | `FileInput` | `Button` file type | +| `IMAGE` | `image` | `FileInputField` + preview | `FileInput` + preview | `Button` file + preview | +| `ORGANISATION_UNIT` | `orgUnit` | DHIS2 `OrgUnitField` | Custom tree picker | Custom tree picker | --- @@ -1005,10 +1006,10 @@ Same as Phase 3 for MUI primitives. These do not block Phase 1 or the dispatcher skeleton, but must be resolved before the relevant widgets are written. -| # | Question | Impact | -| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | -| 1 | **`AGE` storage** — does the field store a date-of-birth string (`DATE` format) and compute age client-side for display, or store the actual age in years? DHIS2 practice is date-of-birth, but needs confirmation. | `AgeField` widget implementation | -| 2 | **`COORDINATE` serialisation** — does the field store as `"lat,lng"` (single string, DHIS2 convention) or are lat and lng submitted as separate form values that are serialised on submit? | `CoordinateField` and tracker payload builder | -| 3 | **`renderTypeHint` enforcement** — should the hook enforce hints (e.g. RADIO vs DROPDOWN for option sets), or is it purely advisory for UI adapter authors? | All option-set widgets | -| 4 | **`ORGANISATION_UNIT` data fetching** — the org unit picker requires a hierarchy query. Should the `OrgUnitField` widget trigger its own `useDataQuery`, or should the parent form pre-fetch and pass options down? | `OrgUnitField` in all adapters | -| 5 | **`multiSelect`** — DHIS2 has `MULTI_TEXT` in newer versions and some programmes use multi-select option sets. Should `WidgetKind` include `'multiSelect'` now, or defer until there is a concrete use case? | `widgetKind.ts`, all `SelectField` widgets | +| # | Question | Impact | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | +| 1 | **`AGE` storage** — does the field store a date-of-birth string (`DATE` format) and compute age client-side for display, or store the actual age in years? DHIS2 practice is date-of-birth, but needs confirmation. | `AgeField` widget implementation | +| 2 | **`COORDINATE` serialisation** — does the field store as `"lat,lng"` (single string, DHIS2 convention) or are lat and lng submitted as separate form values that are serialised on submit? | `CoordinateField` and tracker payload builder | +| 3 | **`renderTypeHint` enforcement** — should the hook enforce hints (e.g. RADIO vs DROPDOWN for option sets), or is it purely advisory for UI adapter authors? | All option-set widgets | +| 4 | **`ORGANISATION_UNIT` data fetching** — the org unit picker requires a hierarchy query. Should the `OrgUnitField` widget trigger its own `useDataQuery`, or should the parent form pre-fetch and pass options down? | `OrgUnitField` in all adapters | +| 5 | **`multiSelect`** — **Resolved:** `WidgetKind` includes `'multiSelect'`. `MULTI_TEXT` + `optionSet` maps to multi-select; RHF stores comma-separated option codes. | `widgetKind.ts`, all `MultiSelectField` widgets | diff --git a/packages/metadata/src/buildTeaFieldSchema.ts b/packages/metadata/src/buildTeaFieldSchema.ts index 8408753..00e04a0 100644 --- a/packages/metadata/src/buildTeaFieldSchema.ts +++ b/packages/metadata/src/buildTeaFieldSchema.ts @@ -1,5 +1,6 @@ import type { ValueType } from '@dhis2/api-types/v43'; import { z } from 'zod'; +import { parseMultiTextValue } from './multiTextValue'; export type TeaFieldInput = { valueType?: ValueType; @@ -9,12 +10,24 @@ export type TeaFieldInput = { }; const todayIso = (): string => new Date().toISOString().slice(0, 10); +const DATETIME_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/; const rejectFutureDates = (schema: z.ZodTypeAny): z.ZodTypeAny => schema.refine((value) => typeof value !== 'string' || value === '' || value <= todayIso(), { message: 'Date cannot be in the future', }); +const multiTextOptionSchema = (codes: string[]): z.ZodTypeAny => { + const codeSet = new Set(codes); + return z.string().refine( + (value) => { + const selected = parseMultiTextValue(value); + return selected.length > 0 && selected.every((code) => codeSet.has(code)); + }, + { message: 'One or more selected options are invalid' } + ); +}; + const valueTypeToStringSchema = (valueType: ValueType | undefined): z.ZodTypeAny => { switch (valueType) { case 'INTEGER': @@ -52,7 +65,7 @@ const valueTypeToStringSchema = (valueType: ValueType | undefined): z.ZodTypeAny case 'DATE': return z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD'); case 'DATETIME': - return z.string().datetime({ message: 'Invalid date-time' }); + return z.string().regex(DATETIME_PATTERN, 'Date-time must be YYYY-MM-DDTHH:mm'); case 'BOOLEAN': return z.enum(['true', 'false', '']); case 'TRUE_ONLY': @@ -81,7 +94,10 @@ export function buildTeaFieldSchema( let base: z.ZodTypeAny; if (codes.length > 0) { - base = z.enum(codes as [string, ...string[]]); + base = + tea.valueType === 'MULTI_TEXT' + ? multiTextOptionSchema(codes) + : z.enum(codes as [string, ...string[]]); } else { base = valueTypeToStringSchema(tea.valueType); } diff --git a/packages/metadata/src/index.ts b/packages/metadata/src/index.ts index 67abeb4..f670fce 100644 --- a/packages/metadata/src/index.ts +++ b/packages/metadata/src/index.ts @@ -34,6 +34,7 @@ export { } from './fieldFilters'; export { buildSchema } from './buildSchema'; export { buildTrackerSchema } from './buildTrackerSchema'; +export { joinMultiTextValue, parseMultiTextValue } from './multiTextValue'; export { CONSTANT_FIELDS, EVENT_PROGRAM_FIELDS, diff --git a/packages/metadata/src/multiTextValue.test.ts b/packages/metadata/src/multiTextValue.test.ts new file mode 100644 index 0000000..87e1451 --- /dev/null +++ b/packages/metadata/src/multiTextValue.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { joinMultiTextValue, parseMultiTextValue } from './multiTextValue'; + +describe('parseMultiTextValue', () => { + it('returns an empty array for an empty string', () => { + expect(parseMultiTextValue('')).toEqual([]); + }); + + it('splits comma-separated codes and trims whitespace', () => { + expect(parseMultiTextValue('YES, NO ,MAYBE')).toEqual(['YES', 'NO', 'MAYBE']); + }); + + it('drops empty tokens', () => { + expect(parseMultiTextValue('YES,,NO,')).toEqual(['YES', 'NO']); + }); +}); + +describe('joinMultiTextValue', () => { + it('joins codes with commas', () => { + expect(joinMultiTextValue(['YES', 'NO'])).toBe('YES,NO'); + }); + + it('returns an empty string for an empty list', () => { + expect(joinMultiTextValue([])).toBe(''); + }); + + it('drops empty codes', () => { + expect(joinMultiTextValue(['YES', '', 'NO'])).toBe('YES,NO'); + }); +}); diff --git a/packages/metadata/src/multiTextValue.ts b/packages/metadata/src/multiTextValue.ts new file mode 100644 index 0000000..7974d7f --- /dev/null +++ b/packages/metadata/src/multiTextValue.ts @@ -0,0 +1,13 @@ +/** Split a DHIS2 MULTI_TEXT form value into option codes. */ +export function parseMultiTextValue(value: string): string[] { + if (value === '') return []; + return value + .split(',') + .map((code) => code.trim()) + .filter((code) => code.length > 0); +} + +/** Join option codes into a DHIS2 MULTI_TEXT form value. */ +export function joinMultiTextValue(codes: readonly string[]): string { + return codes.filter((code) => code.length > 0).join(','); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 968746d..871fea8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -208,12 +208,21 @@ importers: '@mantine/core': specifier: ^7.17.8 version: 7.17.8(@mantine/hooks@7.17.8(react@18.3.1))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@mantine/dates': + specifier: ^7.17.8 + version: 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@18.3.1))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.17.8(react@18.3.1))(dayjs@1.11.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@mantine/hooks': specifier: ^7.17.8 version: 7.17.8(react@18.3.1) '@mui/material': specifier: ^9.0.1 version: 9.0.1(@emotion/react@11.14.0(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@mui/x-date-pickers': + specifier: ^9.10.1 + version: 9.10.1(@emotion/react@11.14.0(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@mui/material@9.0.1(@emotion/react@11.14.0(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mui/system@9.0.1(@emotion/react@11.14.0(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(dayjs@1.11.21)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + dayjs: + specifier: ^1.11.21 + version: 1.11.21 react: specifier: ^18.3.1 version: 18.3.1 @@ -328,6 +337,9 @@ importers: '@mantine/core': specifier: ^7.17.8 version: 7.17.8(@mantine/hooks@7.17.8(react@18.3.1))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@mantine/dates': + specifier: ^7.17.8 + version: 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@18.3.1))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.17.8(react@18.3.1))(dayjs@1.11.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@mantine/hooks': specifier: ^7.17.8 version: 7.17.8(react@18.3.1) @@ -337,6 +349,9 @@ importers: '@types/react-dom': specifier: ^18.3.7 version: 18.3.7(@types/react@18.3.29) + dayjs: + specifier: ^1.11.21 + version: 1.11.21 react: specifier: ^18.3.1 version: 18.3.1 @@ -374,12 +389,18 @@ importers: '@mui/material': specifier: ^9.0.1 version: 9.0.1(@emotion/react@11.14.0(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@mui/x-date-pickers': + specifier: ^9.10.1 + version: 9.10.1(@emotion/react@11.14.0(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@mui/material@9.0.1(@emotion/react@11.14.0(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mui/system@9.0.1(@emotion/react@11.14.0(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(dayjs@1.11.21)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ^18.3.29 version: 18.3.29 '@types/react-dom': specifier: ^18.3.7 version: 18.3.7(@types/react@18.3.29) + dayjs: + specifier: ^1.11.21 + version: 1.11.21 react: specifier: ^18.3.1 version: 18.3.1 @@ -1318,6 +1339,16 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@base-ui/utils@0.3.1': + resolution: {integrity: sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg==} + peerDependencies: + '@types/react': ^17 || ^18 || ^19 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} @@ -2700,6 +2731,15 @@ packages: react: ^18.x || ^19.x react-dom: ^18.x || ^19.x + '@mantine/dates@7.17.8': + resolution: {integrity: sha512-KYog/YL83PnsMef7EZagpOFq9I2gfnK0eYSzC8YvV9Mb6t/x9InqRssGWVb0GIr+TNILpEkhKoGaSKZNy10Q1g==} + peerDependencies: + '@mantine/core': 7.17.8 + '@mantine/hooks': 7.17.8 + dayjs: '>=1.0.0' + react: ^18.x || ^19.x + react-dom: ^18.x || ^19.x + '@mantine/hooks@7.17.8': resolution: {integrity: sha512-96qygbkTjRhdkzd5HDU8fMziemN/h758/EwrFu7TlWrEP10Vw076u+Ap/sG6OT4RGPZYYoHrTlT+mkCZblWHuw==} peerDependencies: @@ -2785,6 +2825,14 @@ packages: '@types/react': optional: true + '@mui/types@9.1.1': + resolution: {integrity: sha512-Zjt7u8wNvDg40rPTGoL+TnfkpuSKjwubsNSFRH1KAVZLcaV4I3AFNHIFbvH7p4F3alEibSbdd90xAgn5Rnfndg==} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@mui/utils@9.0.1': resolution: {integrity: sha512-f3UO3jNN1pYg5zxqXC81Bvv8hx5ACcYc0387382ZI7M5ono1heIwHYLrKsz85myguWdeVKPRZGmDdynWUBjK2g==} engines: {node: '>=14.0.0'} @@ -2795,6 +2843,59 @@ packages: '@types/react': optional: true + '@mui/utils@9.2.0': + resolution: {integrity: sha512-OsUH5zhlSOM4xmLl53+agug1M1UyWb4zxFxWQCqwKTKUeQPvTENtg3JhrroBD2qpCLKsX5W/DYGERJ4mBUbc8g==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@mui/x-date-pickers@9.10.1': + resolution: {integrity: sha512-WMGSi6Ao3d1PMqakJ4XtLzEvbvawT3FhiySFLyJ4G39yC9ILUwbUsJWECV0m9trmiriXmhgjf1l7a9nOyY2lBQ==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@emotion/react': ^11.9.0 + '@emotion/styled': ^11.8.1 + '@mui/material': ^7.3.0 || ^9.0.0 + '@mui/system': ^7.3.0 || ^9.0.0 + date-fns: ^2.25.0 || ^3.2.0 || ^4.0.0 + date-fns-jalali: ^2.13.0-0 || ^3.2.0-0 || ^4.0.0-0 + dayjs: ^1.10.7 + luxon: ^3.0.2 + moment: ^2.29.4 + moment-hijri: ^2.1.2 || ^3.0.0 + moment-jalaali: ^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + date-fns: + optional: true + date-fns-jalali: + optional: true + dayjs: + optional: true + luxon: + optional: true + moment: + optional: true + moment-hijri: + optional: true + moment-jalaali: + optional: true + + '@mui/x-internals@9.10.1': + resolution: {integrity: sha512-pKFVzFQHB34TuMG3gOVx+LfIR8VBsi/ZhX/erRX2WOXu+ewEYGvdeK1mW9hLe7e2v3uyUbgT8RNeN0cy10cDjg==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + '@napi-rs/wasm-runtime@1.1.6': resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} peerDependencies: @@ -5096,6 +5197,9 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -7752,6 +7856,9 @@ packages: requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + reselect@5.2.0: + resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} + resize-observer-polyfill@1.5.1: resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} @@ -8453,16 +8560,9 @@ packages: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} - tldts-core@7.0.30: - resolution: {integrity: sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==} - tldts-core@7.4.9: resolution: {integrity: sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==} - tldts@7.0.30: - resolution: {integrity: sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==} - hasBin: true - tldts@7.4.9: resolution: {integrity: sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==} hasBin: true @@ -8509,10 +8609,6 @@ packages: resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} engines: {node: '>=6'} - tough-cookie@6.0.1: - resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} - engines: {node: '>=16'} - tough-cookie@6.0.2: resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} engines: {node: '>=16'} @@ -10234,6 +10330,17 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@base-ui/utils@0.3.1(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + '@floating-ui/utils': 0.2.11 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + reselect: 5.2.0 + use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@bcoe/v8-coverage@0.2.3': {} '@bcoe/v8-coverage@1.0.2': {} @@ -11256,7 +11363,7 @@ snapshots: '@emotion/babel-plugin@11.13.5': dependencies: '@babel/helper-module-imports': 7.28.6 - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@emotion/hash': 0.9.2 '@emotion/memoize': 0.9.0 '@emotion/serialize': 1.3.3 @@ -11982,6 +12089,15 @@ snapshots: transitivePeerDependencies: - '@types/react' + '@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@18.3.1))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.17.8(react@18.3.1))(dayjs@1.11.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@18.3.1))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@mantine/hooks': 7.17.8(react@18.3.1) + clsx: 2.1.1 + dayjs: 1.11.21 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + '@mantine/hooks@7.17.8(react@18.3.1)': dependencies: react: 18.3.1 @@ -12026,8 +12142,8 @@ snapshots: '@mui/private-theming@9.0.1(@types/react@18.3.29)(react@18.3.1)': dependencies: - '@babel/runtime': 7.29.2 - '@mui/utils': 9.0.1(@types/react@18.3.29)(react@18.3.1) + '@babel/runtime': 7.29.7 + '@mui/utils': 9.2.0(@types/react@18.3.29)(react@18.3.1) prop-types: 15.8.1 react: 18.3.1 optionalDependencies: @@ -12035,7 +12151,7 @@ snapshots: '@mui/styled-engine@9.0.0(@emotion/react@11.14.0(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(react@18.3.1)': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@emotion/cache': 11.14.0 '@emotion/serialize': 1.3.3 '@emotion/sheet': 1.4.0 @@ -12048,11 +12164,11 @@ snapshots: '@mui/system@9.0.1(@emotion/react@11.14.0(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1)': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@mui/private-theming': 9.0.1(@types/react@18.3.29)(react@18.3.1) '@mui/styled-engine': 9.0.0(@emotion/react@11.14.0(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(react@18.3.1) - '@mui/types': 9.0.0(@types/react@18.3.29) - '@mui/utils': 9.0.1(@types/react@18.3.29)(react@18.3.1) + '@mui/types': 9.1.1(@types/react@18.3.29) + '@mui/utils': 9.2.0(@types/react@18.3.29)(react@18.3.1) clsx: 2.1.1 csstype: 3.2.3 prop-types: 15.8.1 @@ -12064,13 +12180,19 @@ snapshots: '@mui/types@9.0.0(@types/react@18.3.29)': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 + optionalDependencies: + '@types/react': 18.3.29 + + '@mui/types@9.1.1(@types/react@18.3.29)': + dependencies: + '@babel/runtime': 7.29.7 optionalDependencies: '@types/react': 18.3.29 '@mui/utils@9.0.1(@types/react@18.3.29)(react@18.3.1)': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@mui/types': 9.0.0(@types/react@18.3.29) '@types/prop-types': 15.7.15 clsx: 2.1.1 @@ -12080,6 +12202,51 @@ snapshots: optionalDependencies: '@types/react': 18.3.29 + '@mui/utils@9.2.0(@types/react@18.3.29)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + '@mui/types': 9.1.1(@types/react@18.3.29) + '@types/prop-types': 15.7.15 + clsx: 2.1.1 + prop-types: 15.8.1 + react: 18.3.1 + react-is: 19.2.8 + optionalDependencies: + '@types/react': 18.3.29 + + '@mui/x-date-pickers@9.10.1(@emotion/react@11.14.0(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@mui/material@9.0.1(@emotion/react@11.14.0(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mui/system@9.0.1(@emotion/react@11.14.0(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(dayjs@1.11.21)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + '@mui/material': 9.0.1(@emotion/react@11.14.0(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@mui/system': 9.0.1(@emotion/react@11.14.0(@types/react@18.3.29)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1) + '@mui/utils': 9.2.0(@types/react@18.3.29)(react@18.3.1) + '@mui/x-internals': 9.10.1(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react-transition-group': 4.4.12(@types/react@18.3.29) + clsx: 2.1.1 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + optionalDependencies: + '@emotion/react': 11.14.0(@types/react@18.3.29)(react@18.3.1) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.29)(react@18.3.1))(@types/react@18.3.29)(react@18.3.1) + dayjs: 1.11.21 + moment: 2.30.1 + transitivePeerDependencies: + - '@types/react' + + '@mui/x-internals@9.10.1(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + '@base-ui/utils': 0.3.1(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@mui/utils': 9.2.0(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + reselect: 5.2.0 + use-sync-external-store: 1.6.0(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + - react-dom + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 @@ -13475,6 +13642,10 @@ snapshots: dependencies: acorn: 8.16.0 + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + acorn-private-class-elements@0.2.7(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -13779,7 +13950,7 @@ snapshots: babel-plugin-macros@3.1.0: dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 cosmiconfig: 7.1.0 resolve: 1.22.12 @@ -14390,6 +14561,8 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + dayjs@1.11.21: {} + debug@3.2.7: dependencies: ms: 2.1.3 @@ -14478,7 +14651,7 @@ snapshots: dom-helpers@5.2.1: dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 csstype: 3.2.3 dom-serializer@1.4.1: @@ -15009,8 +15182,8 @@ snapshots: espree@11.2.0: dependencies: - acorn: 8.16.0 - acorn-jsx: 5.3.2(acorn@8.16.0) + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) eslint-visitor-keys: 5.0.1 esprima-next@5.8.4: {} @@ -15186,7 +15359,7 @@ snapshots: final-form@4.20.10: dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 find-cache-dir@3.3.2: dependencies: @@ -16400,7 +16573,7 @@ snapshots: parse5: 8.0.1 saxes: 6.0.0 symbol-tree: 3.2.4 - tough-cookie: 6.0.1 + tough-cookie: 6.0.2 undici: 7.25.0 w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 @@ -16692,8 +16865,8 @@ snapshots: magicast@0.5.3: dependencies: - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 source-map-js: 1.2.1 make-dir@1.3.0: @@ -16706,7 +16879,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.8.1 + semver: 7.8.5 makeerror@1.0.12: dependencies: @@ -17323,7 +17496,7 @@ snapshots: react-final-form@6.5.9(final-form@4.20.10)(react@18.3.1): dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 final-form: 4.20.10 react: 18.3.1 @@ -17395,7 +17568,7 @@ snapshots: react-textarea-autosize@8.5.9(@types/react@18.3.29)(react@18.3.1): dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 react: 18.3.1 use-composed-ref: 1.4.0(@types/react@18.3.29)(react@18.3.1) use-latest: 1.3.0(@types/react@18.3.29)(react@18.3.1) @@ -17404,7 +17577,7 @@ snapshots: react-transition-group@4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 dom-helpers: 5.2.1 loose-envify: 1.4.0 prop-types: 15.8.1 @@ -17567,6 +17740,8 @@ snapshots: requires-port@1.0.0: {} + reselect@5.2.0: {} + resize-observer-polyfill@1.5.1: {} resolve-from@4.0.0: {} @@ -18305,14 +18480,8 @@ snapshots: tinyspy@4.0.4: {} - tldts-core@7.0.30: {} - tldts-core@7.4.9: {} - tldts@7.0.30: - dependencies: - tldts-core: 7.0.30 - tldts@7.4.9: dependencies: tldts-core: 7.4.9 @@ -18364,10 +18533,6 @@ snapshots: universalify: 0.2.0 url-parse: 1.5.10 - tough-cookie@6.0.1: - dependencies: - tldts: 7.0.30 - tough-cookie@6.0.2: dependencies: tldts: 7.4.9 @@ -19055,7 +19220,7 @@ snapshots: '@apideck/better-ajv-errors': 0.3.7(ajv@8.20.0) '@babel/core': 7.29.0 '@babel/preset-env': 7.29.5(@babel/core@7.29.0) - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@rollup/plugin-babel': 6.1.0(@babel/core@7.29.0)(@types/babel__core@7.20.5)(rollup@4.60.4) '@rollup/plugin-node-resolve': 16.0.3(rollup@4.60.4) '@rollup/plugin-replace': 6.0.3(rollup@4.60.4) diff --git a/utils/hooks/src/fields/computeAgeFromDob.test.ts b/utils/hooks/src/fields/computeAgeFromDob.test.ts new file mode 100644 index 0000000..57ab107 --- /dev/null +++ b/utils/hooks/src/fields/computeAgeFromDob.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { computeAgeFromDob } from './computeAgeFromDob'; + +describe('computeAgeFromDob', () => { + it('returns empty string for invalid dates', () => { + expect(computeAgeFromDob('')).toBe(''); + expect(computeAgeFromDob('not-a-date')).toBe(''); + }); + + it('computes whole years from a date of birth', () => { + expect(computeAgeFromDob('2000-01-01')).toMatch(/^\d+$/); + expect(Number(computeAgeFromDob('2000-01-01'))).toBeGreaterThan(0); + }); +}); diff --git a/utils/hooks/src/fields/computeAgeFromDob.ts b/utils/hooks/src/fields/computeAgeFromDob.ts new file mode 100644 index 0000000..7b7efb5 --- /dev/null +++ b/utils/hooks/src/fields/computeAgeFromDob.ts @@ -0,0 +1,13 @@ +/** Compute whole years of age from a YYYY-MM-DD date of birth string. */ +export function computeAgeFromDob(dob: string): string { + if (!/^\d{4}-\d{2}-\d{2}$/.test(dob)) return ''; + const birth = new Date(dob); + if (Number.isNaN(birth.getTime())) return ''; + const today = new Date(); + let age = today.getFullYear() - birth.getFullYear(); + const monthDiff = today.getMonth() - birth.getMonth(); + if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) { + age -= 1; + } + return age >= 0 ? String(age) : ''; +} diff --git a/utils/hooks/src/fields/fieldValidation.test.ts b/utils/hooks/src/fields/fieldValidation.test.ts index 21ed6f8..36f52e8 100644 --- a/utils/hooks/src/fields/fieldValidation.test.ts +++ b/utils/hooks/src/fields/fieldValidation.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { fromProgramStageDataElement } from './fieldConfig'; import { buildFieldSchema } from './fieldValidation'; -import { makePsde } from '../test/fixtures/metadata'; +import { makePsde, makePsdeWithOptionSet } from '../test/fixtures/metadata'; describe('buildFieldSchema', () => { it('rejects invalid INTEGER values when required', () => { @@ -29,4 +29,25 @@ describe('buildFieldSchema', () => { expect(schema.safeParse('not-an-email').success).toBe(false); expect(schema.safeParse('user@example.com').success).toBe(true); }); + + it('validates MULTI_TEXT option-set values as comma-separated codes', () => { + const schema = buildFieldSchema({ + ...fromProgramStageDataElement(makePsdeWithOptionSet('de-multi', 'MULTI_TEXT')), + required: true, + }); + expect(schema.safeParse('YES,NO').success).toBe(true); + expect(schema.safeParse('YES').success).toBe(true); + expect(schema.safeParse('YES,UNKNOWN').success).toBe(false); + expect(schema.safeParse('').success).toBe(false); + }); + + it('validates DATETIME as YYYY-MM-DDTHH:mm', () => { + const schema = buildFieldSchema({ + ...fromProgramStageDataElement(makePsde('de-dt', 'DATETIME')), + required: true, + }); + expect(schema.safeParse('2024-01-15T14:30').success).toBe(true); + expect(schema.safeParse('2024-01-15T14:30:00Z').success).toBe(false); + expect(schema.safeParse('2024-01-15').success).toBe(false); + }); }); diff --git a/utils/hooks/src/fields/fieldValidation.ts b/utils/hooks/src/fields/fieldValidation.ts index e810466..c319328 100644 --- a/utils/hooks/src/fields/fieldValidation.ts +++ b/utils/hooks/src/fields/fieldValidation.ts @@ -1,5 +1,19 @@ import { z } from 'zod'; import type { FieldConfig } from './fieldConfig'; +import { parseMultiTextValue } from './multiTextValue'; + +const DATETIME_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/; + +const multiTextOptionSchema = (codes: string[]): z.ZodTypeAny => { + const codeSet = new Set(codes); + return z.string().refine( + (value) => { + const selected = parseMultiTextValue(value); + return selected.length > 0 && selected.every((code) => codeSet.has(code)); + }, + { message: 'One or more selected options are invalid' } + ); +}; /** * Per-field Zod schema for useController validation. @@ -12,6 +26,8 @@ export function buildFieldSchema(config: FieldConfig): z.ZodTypeAny { const codes = config.optionSet.options.map((option) => option.code); if (codes.length === 0) { base = z.string(); + } else if (config.valueType === 'MULTI_TEXT') { + base = multiTextOptionSchema(codes); } else { base = z.enum(codes as [string, ...string[]]); } @@ -61,7 +77,7 @@ export function buildFieldSchema(config: FieldConfig): z.ZodTypeAny { base = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD'); break; case 'DATETIME': - base = z.string().datetime({ message: 'Invalid date-time' }); + base = z.string().regex(DATETIME_PATTERN, 'Date-time must be YYYY-MM-DDTHH:mm'); break; case 'BOOLEAN': base = z.enum(['true', 'false', '']); diff --git a/utils/hooks/src/fields/multiTextValue.test.ts b/utils/hooks/src/fields/multiTextValue.test.ts new file mode 100644 index 0000000..4230de6 --- /dev/null +++ b/utils/hooks/src/fields/multiTextValue.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest'; +import { joinMultiTextValue, parseMultiTextValue } from './multiTextValue'; + +describe('multiTextValue re-exports', () => { + it('parses and joins comma-separated codes', () => { + expect(parseMultiTextValue('YES, NO')).toEqual(['YES', 'NO']); + expect(joinMultiTextValue(['YES', 'NO'])).toBe('YES,NO'); + }); +}); diff --git a/utils/hooks/src/fields/multiTextValue.ts b/utils/hooks/src/fields/multiTextValue.ts new file mode 100644 index 0000000..3bd2a56 --- /dev/null +++ b/utils/hooks/src/fields/multiTextValue.ts @@ -0,0 +1 @@ +export { joinMultiTextValue, parseMultiTextValue } from '@dhis2-form-utils/metadata'; diff --git a/utils/hooks/src/fields/widgetKind.test.ts b/utils/hooks/src/fields/widgetKind.test.ts index 9df60f4..0bd1aa1 100644 --- a/utils/hooks/src/fields/widgetKind.test.ts +++ b/utils/hooks/src/fields/widgetKind.test.ts @@ -4,11 +4,22 @@ import { resolveWidgetKind } from './widgetKind'; import { makePsde, makePsdeWithOptionSet } from '../test/fixtures/metadata'; describe('resolveWidgetKind', () => { - it('returns select when optionSet is present regardless of valueType', () => { + it('returns select when optionSet is present for non-MULTI_TEXT valueTypes', () => { const config = fromProgramStageDataElement(makePsdeWithOptionSet('de-1', 'INTEGER')); expect(resolveWidgetKind(config)).toBe('select'); }); + it('returns multiSelect when optionSet is present for MULTI_TEXT', () => { + const config = fromProgramStageDataElement(makePsdeWithOptionSet('de-1', 'MULTI_TEXT')); + expect(resolveWidgetKind(config)).toBe('multiSelect'); + }); + + it('maps MULTI_TEXT without optionSet to longText', () => { + expect(resolveWidgetKind(fromProgramStageDataElement(makePsde('de-1', 'MULTI_TEXT')))).toBe( + 'longText' + ); + }); + it('maps TEXT to text', () => { expect(resolveWidgetKind(fromProgramStageDataElement(makePsde('de-1', 'TEXT')))).toBe( 'text' diff --git a/utils/hooks/src/fields/widgetKind.ts b/utils/hooks/src/fields/widgetKind.ts index d5c1587..dd6282f 100644 --- a/utils/hooks/src/fields/widgetKind.ts +++ b/utils/hooks/src/fields/widgetKind.ts @@ -15,6 +15,7 @@ export type WidgetKind = | 'time' | 'age' | 'select' + | 'multiSelect' | 'orgUnit' | 'coordinate' | 'file' @@ -22,7 +23,9 @@ export type WidgetKind = | 'unsupported'; export function resolveWidgetKind(config: FieldConfig): WidgetKind { - if (config.optionSet) return 'select'; + if (config.optionSet) { + return config.valueType === 'MULTI_TEXT' ? 'multiSelect' : 'select'; + } switch (config.valueType) { case 'TEXT': diff --git a/utils/hooks/src/index.ts b/utils/hooks/src/index.ts index c04ea37..354d0ad 100644 --- a/utils/hooks/src/index.ts +++ b/utils/hooks/src/index.ts @@ -38,6 +38,8 @@ export type { FieldControlInput, FieldControlReturn, WidgetProps } from './field export { useFieldControl } from './fields/useFieldControl'; export { resolveWidgetKind } from './fields/widgetKind'; export { buildFieldSchema } from './fields/fieldValidation'; +export { joinMultiTextValue, parseMultiTextValue } from './fields/multiTextValue'; export { resolveFieldValidation } from './fields/fieldFeedback'; +export { computeAgeFromDob } from './fields/computeAgeFromDob'; export { useRuleEffectTrace, useFieldRuleEffect, useSectionRuleEffect } from './useRuleEffectTrace'; export type { RuleEffectTrace } from './useRuleEffectTrace'; diff --git a/utils/rules/src/filterPayload.test.ts b/utils/rules/src/filterPayload.test.ts index b8e6bc0..76deae1 100644 --- a/utils/rules/src/filterPayload.test.ts +++ b/utils/rules/src/filterPayload.test.ts @@ -46,6 +46,24 @@ describe('filterPayload', () => { expect(filterPayload({ de1: 'opt2' }, fieldState)).toEqual({ de1: 'opt2' }); }); + it('strips hidden codes from a comma-separated MULTI_TEXT value', () => { + const fieldState: FieldStateMap = { + de1: { ...createEmptyFieldState(), hiddenOptions: new Set(['opt1']) }, + }; + + expect(filterPayload({ de1: 'opt1,opt2,opt3' }, fieldState)).toEqual({ + de1: 'opt2,opt3', + }); + }); + + it('nulls a MULTI_TEXT value when every code is hidden', () => { + const fieldState: FieldStateMap = { + de1: { ...createEmptyFieldState(), hiddenOptions: new Set(['opt1', 'opt2']) }, + }; + + expect(filterPayload({ de1: 'opt1,opt2' }, fieldState)).toEqual({ de1: null }); + }); + it('only guards against directly hidden options when optionGroups is omitted', () => { const fieldState: FieldStateMap = { de1: { ...createEmptyFieldState(), hiddenOptionGroups: new Set(['og1']) }, diff --git a/utils/rules/src/filterPayload.ts b/utils/rules/src/filterPayload.ts index 2fcffc1..e540068 100644 --- a/utils/rules/src/filterPayload.ts +++ b/utils/rules/src/filterPayload.ts @@ -1,7 +1,24 @@ import type { OptionGroupCodeMap } from '@dhis2-form-utils/metadata'; +import { parseMultiTextValue } from '@dhis2-form-utils/metadata'; import { resolveHiddenOptionCodes } from './resolveHiddenOptionCodes'; import type { FieldStateMap } from './types'; +const filterHiddenCodes = (value: string, hiddenCodes: Set): string | null => { + const codes = parseMultiTextValue(value); + if (codes.length === 0) { + return hiddenCodes.has(value) ? null : value; + } + + if (codes.length === 1) { + const [onlyCode] = codes; + return hiddenCodes.has(onlyCode) ? null : onlyCode; + } + + const remaining = codes.filter((code) => !hiddenCodes.has(code)); + if (remaining.length === 0) return null; + return remaining.join(','); +}; + export function filterPayload( values: Record, fieldState: FieldStateMap, @@ -20,11 +37,13 @@ export function filterPayload( if (state.assignedValue !== null && state.assignedValue !== undefined) { result[key] = state.assignedValue; - } else if ( - typeof value === 'string' && - resolveHiddenOptionCodes(state, optionGroups).has(value) - ) { - result[key] = null; + } else if (typeof value === 'string') { + const hiddenCodes = resolveHiddenOptionCodes(state, optionGroups); + if (hiddenCodes.size === 0) { + result[key] = value; + } else { + result[key] = filterHiddenCodes(value, hiddenCodes); + } } else { result[key] = value; }