diff --git a/packages/jsonforms-renderers/dev/fixtures.ts b/packages/jsonforms-renderers/dev/fixtures.ts index 5ab9640..777103c 100644 --- a/packages/jsonforms-renderers/dev/fixtures.ts +++ b/packages/jsonforms-renderers/dev/fixtures.ts @@ -421,7 +421,12 @@ export const fixtures: Fixture[] = [ properties: { eventDate: { type: 'string', format: 'date', description: 'Date only (yyyy-MM-dd)' }, appointment: { type: 'string', format: 'date-time', description: 'Date + time (RFC 3339)' }, - openingTime: { type: 'string', format: 'time', description: 'Time only (native picker)' }, + openingTime: { type: 'string', format: 'time', description: 'Time only (native picker, minute precision)' }, + closingTime: { + type: 'string', + format: 'time', + description: 'Time only with seconds spinner (options.showSeconds)', + }, }, required: ['eventDate'], }, @@ -431,6 +436,7 @@ export const fixtures: Fixture[] = [ { type: 'Control', scope: '#/properties/eventDate' }, { type: 'Control', scope: '#/properties/appointment' }, { type: 'Control', scope: '#/properties/openingTime' }, + { type: 'Control', scope: '#/properties/closingTime', options: { showSeconds: true } }, ], } as UISchemaElement, }, diff --git a/packages/jsonforms-renderers/docs/date-control.md b/packages/jsonforms-renderers/docs/date-control.md new file mode 100644 index 0000000..8aa5e46 --- /dev/null +++ b/packages/jsonforms-renderers/docs/date-control.md @@ -0,0 +1,40 @@ +# Date and time (`DateControl`) + +`DateControl` renders JSON Schema `string` fields with `format` of `date`, `time`, or `date-time`, using native browser pickers. + +See the `date` fixture in `dev/fixtures.ts` for a runnable example (`eventDate`, `appointment`, `openingTime`, `closingTime`). + +## Formats + +| Schema `format` | Stored value | UI | +| --------------- | ------------------------------------------- | -------------------------------------------------------- | +| `date` | `YYYY-MM-DD` | Native date input | +| `time` | RFC 3339 `HH:MM:SS` | Native time input | +| `date-time` | RFC 3339 date-time (seconds + local offset) | Date and time inputs side by side, sharing width equally | + +`time` and `date-time` share the same native time input. With `date` alone the date input expands to the full control width. + +## Stored time is always `HH:MM:SS` + +AJV's `format: "time"` is RFC 3339, which requires seconds. Native `` with minute precision (the default) emits `HH:MM`. `DateControl` pads that to `HH:MM:SS` before writing form data (`14:30` → `14:30:00`) via [`utils/time.ts`](../src/utils/time.ts). Clearing the picker still clears the field. + +Artifact schemas that declare `format: "time"` do not need to change. + +## Seconds spinner (`options.showSeconds`) + +The seconds spinner is **opt-in**. Set `options.showSeconds: true` on the **Control** in the uischema — it applies to both `time` and `date-time`: + +```json +{ + "type": "Control", + "scope": "#/properties/proposed_inspection_time", + "options": { "showSeconds": true } +} +``` + +| `showSeconds` | Picker | Stored value | +| ----------------- | ------------------------------------ | ------------------------------------------------------------------- | +| omitted / `false` | Hour and minute only (`step=60`) | Seconds default to `00` | +| `true` | Hour, minute, and seconds (`step=1`) | Seconds as picked; still `00` if the user only sets hour and minute | + +A form that omits `showSeconds` keeps the hour-and-minute picker and still submits a valid `HH:MM:SS` string. diff --git a/packages/jsonforms-renderers/src/renderers/DateControl.test.tsx b/packages/jsonforms-renderers/src/renderers/DateControl.test.tsx new file mode 100644 index 0000000..0e40d18 --- /dev/null +++ b/packages/jsonforms-renderers/src/renderers/DateControl.test.tsx @@ -0,0 +1,143 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from 'vitest' +import { useState } from 'react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { JsonForms } from '@jsonforms/react' +import { Theme } from '@radix-ui/themes' +import type { JsonSchema, UISchemaElement } from '@jsonforms/core' +import { radixRenderers } from './index' + +type Data = Record + +const finishers: (() => void)[] = [] + +afterEach(() => { + for (const stop of finishers) stop() + finishers.length = 0 + cleanup() +}) + +const openingTimeSchema: JsonSchema = { + type: 'object', + properties: { + openingTime: { type: 'string', format: 'time', title: 'Opening Time' }, + }, +} + +const appointmentSchema: JsonSchema = { + type: 'object', + properties: { + appointment: { type: 'string', format: 'date-time', title: 'Appointment' }, + }, +} + +function renderForm(schema: JsonSchema, uischema: UISchemaElement, seed: Data) { + const writes: Data[] = [] + let live = true + finishers.push(() => { + live = false + }) + + function Harness() { + const [initial] = useState(seed) + return ( + + { + if (live) writes.push(data as Data) + }} + /> + + ) + } + + render() + return { writes } +} + +const timeControl = { + type: 'VerticalLayout', + elements: [{ type: 'Control', scope: '#/properties/openingTime' }], +} as UISchemaElement + +const secondsControl = { + type: 'VerticalLayout', + elements: [{ type: 'Control', scope: '#/properties/openingTime', options: { showSeconds: true } }], +} as UISchemaElement + +const appointmentControl = { + type: 'VerticalLayout', + elements: [{ type: 'Control', scope: '#/properties/appointment' }], +} as UISchemaElement + +const appointmentSecondsControl = { + type: 'VerticalLayout', + elements: [{ type: 'Control', scope: '#/properties/appointment', options: { showSeconds: true } }], +} as UISchemaElement + +describe('DateControl time', () => { + it('stores HH:MM:SS when the picker emits HH:MM', async () => { + const { writes } = renderForm(openingTimeSchema, timeControl, {}) + const input = screen.getByLabelText(/Opening Time/) + + fireEvent.change(input, { target: { value: '14:30' } }) + + await waitFor(() => { + expect(writes.at(-1)).toEqual({ openingTime: '14:30:00' }) + }) + }) + + it('clears the time when the picker value is empty', async () => { + const { writes } = renderForm(openingTimeSchema, timeControl, { openingTime: '14:30:00' }) + const input = screen.getByLabelText(/Opening Time/) + + fireEvent.change(input, { target: { value: '' } }) + + await waitFor(() => { + expect(writes.at(-1)?.openingTime).toBeUndefined() + }) + }) + + it('uses minute step unless showSeconds is set', () => { + renderForm(openingTimeSchema, timeControl, { openingTime: '14:30:00' }) + expect(screen.getByLabelText(/Opening Time/).getAttribute('step')).toBe('60') + expect((screen.getByLabelText(/Opening Time/) as HTMLInputElement).value).toBe('14:30') + }) + + it('shows a seconds spinner when showSeconds is true', async () => { + const { writes } = renderForm(openingTimeSchema, secondsControl, {}) + const input = screen.getByLabelText(/Opening Time/) + + expect(input.getAttribute('step')).toBe('1') + + fireEvent.change(input, { target: { value: '14:30' } }) + await waitFor(() => { + expect(writes.at(-1)).toEqual({ openingTime: '14:30:00' }) + }) + + fireEvent.change(input, { target: { value: '14:30:45' } }) + await waitFor(() => { + expect(writes.at(-1)).toEqual({ openingTime: '14:30:45' }) + }) + }) +}) + +describe('DateControl date-time', () => { + it('uses minute step on the time input unless showSeconds is set', () => { + renderForm(appointmentSchema, appointmentControl, { appointment: '2026-10-09T14:30:00' }) + const timeInput = document.querySelector('input[type="time"]') + expect(timeInput?.getAttribute('step')).toBe('60') + expect((timeInput as HTMLInputElement).value).toBe('14:30') + }) + + it('shows a seconds spinner on date-time when showSeconds is true', () => { + renderForm(appointmentSchema, appointmentSecondsControl, { appointment: '2026-10-09T14:30:45' }) + const timeInput = document.querySelector('input[type="time"]') + expect(timeInput?.getAttribute('step')).toBe('1') + expect((timeInput as HTMLInputElement).value).toBe('14:30:45') + }) +}) diff --git a/packages/jsonforms-renderers/src/renderers/DateControl.tsx b/packages/jsonforms-renderers/src/renderers/DateControl.tsx index 4210cae..c685571 100644 --- a/packages/jsonforms-renderers/src/renderers/DateControl.tsx +++ b/packages/jsonforms-renderers/src/renderers/DateControl.tsx @@ -13,6 +13,7 @@ import { useEffect, type ReactNode } from 'react' import dayjs from 'dayjs' import { getErrorMessage } from '../utils/error' +import { timeInputValue, toRfc3339Time } from '../utils/time' // dayjs().format() defaults to RFC 3339 (e.g. 2026-06-05T12:30:00+05:30) with // seconds + local offset, which ajv's strict "date-time" format check requires. @@ -55,6 +56,7 @@ export const DateControl = ({ required, errors, schema, + uischema, enabled, visible = true, }: ControlProps) => { @@ -69,6 +71,10 @@ export const DateControl = ({ } const isValid = errors.length === 0 const value: string = typeof data === 'string' ? data : '' + // Seconds spinner is opt-in. Omitted / false keeps hour+minute only. + // Stored values always include seconds so AJV format "time" still passes. + const showSeconds = uischema?.options?.showSeconds === true + const timeStep = showSeconds ? 1 : 60 const shell = (children: ReactNode) => ( @@ -81,8 +87,9 @@ export const DateControl = ({ return shell( handleChange(path, e.target.value)} + step={timeStep} + value={timeInputValue(value, showSeconds)} + onChange={(e) => handleChange(path, toRfc3339Time(e.target.value))} disabled={!enabled} color={!isValid ? 'red' : undefined} id={path} @@ -93,9 +100,7 @@ export const DateControl = ({ // date and date-time share the same date input; date-time appends a native time input. const hasTime = schema.format === 'date-time' const [datePart = '', timeRaw = ''] = value.split('T') - // The native only understands HH:MM(:SS); strip any - // timezone suffix from the stored RFC 3339 value before feeding it back. - const timeForInput = timeRaw.slice(0, 5) + const timeForInput = timeInputValue(timeRaw, showSeconds) const commit = (nextDate: string, nextTime: string) => { if (!nextDate) { @@ -119,6 +124,7 @@ export const DateControl = ({ {hasTime && ( { + it('pads HH:MM with zero seconds', () => { + expect(toRfc3339Time('14:30')).toBe('14:30:00') + }) + + it('leaves HH:MM:SS unchanged', () => { + expect(toRfc3339Time('14:30:45')).toBe('14:30:45') + }) + + it('clears an empty picker value', () => { + expect(toRfc3339Time('')).toBeUndefined() + }) +}) + +describe('timeInputValue', () => { + it('drops seconds when the seconds spinner is off', () => { + expect(timeInputValue('14:30:00', false)).toBe('14:30') + }) + + it('keeps seconds when the seconds spinner is on', () => { + expect(timeInputValue('14:30:45', true)).toBe('14:30:45') + }) + + it('fills missing seconds for the seconds spinner', () => { + expect(timeInputValue('14:30', true)).toBe('14:30:00') + }) +}) diff --git a/packages/jsonforms-renderers/src/utils/time.ts b/packages/jsonforms-renderers/src/utils/time.ts new file mode 100644 index 0000000..cf1438b --- /dev/null +++ b/packages/jsonforms-renderers/src/utils/time.ts @@ -0,0 +1,14 @@ +/** Normalize a native time input value for AJV `format: "time"` (RFC 3339 `HH:MM:SS`). */ +export const toRfc3339Time = (raw: string): string | undefined => { + if (!raw) return undefined + if (/^\d{2}:\d{2}$/.test(raw)) return `${raw}:00` + return raw +} + +/** Format a stored time for a native ``, optionally hiding seconds. */ +export const timeInputValue = (stored: string, showSeconds: boolean): string => { + if (!stored) return '' + const match = stored.match(/^(\d{2}:\d{2})(:\d{2})?/) + if (!match) return stored + return showSeconds ? `${match[1]}${match[2] ?? ':00'}` : match[1] +}