Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion packages/jsonforms-renderers/dev/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
Expand All @@ -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,
},
Expand Down
40 changes: 40 additions & 0 deletions packages/jsonforms-renderers/docs/date-control.md
Original file line number Diff line number Diff line change
@@ -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 `<input type="time">` 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.
143 changes: 143 additions & 0 deletions packages/jsonforms-renderers/src/renderers/DateControl.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>

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 (
<Theme>
<JsonForms
schema={schema}
uischema={uischema}
data={initial}
renderers={radixRenderers}
onChange={({ data }) => {
if (live) writes.push(data as Data)
}}
/>
</Theme>
)
}

render(<Harness />)
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')

Check failure on line 108 in packages/jsonforms-renderers/src/renderers/DateControl.test.tsx

View workflow job for this annotation

GitHub Actions / Quality Check & Build

This assertion is unnecessary since it does not change the type of the expression
})

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')
})
})
16 changes: 11 additions & 5 deletions packages/jsonforms-renderers/src/renderers/DateControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -55,6 +56,7 @@ export const DateControl = ({
required,
errors,
schema,
uischema,
enabled,
visible = true,
}: ControlProps) => {
Expand All @@ -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) => (
<FieldShell path={path} label={label} required={required} errors={errors} description={schema.description}>
Expand All @@ -81,8 +87,9 @@ export const DateControl = ({
return shell(
<TextField.Root
type="time"
value={value}
onChange={(e) => 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}
Expand All @@ -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 <input type="time"> 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) {
Expand All @@ -119,6 +124,7 @@ export const DateControl = ({
{hasTime && (
<TextField.Root
type="time"
step={timeStep}
Comment thread
sthanikan2000 marked this conversation as resolved.
value={timeForInput}
disabled={!enabled || !datePart}
color={!isValid ? 'red' : undefined}
Expand Down
30 changes: 30 additions & 0 deletions packages/jsonforms-renderers/src/utils/time.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { timeInputValue, toRfc3339Time } from './time'

describe('toRfc3339Time', () => {
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')
})
})
14 changes: 14 additions & 0 deletions packages/jsonforms-renderers/src/utils/time.ts
Original file line number Diff line number Diff line change
@@ -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 `<input type="time">`, 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]
}
Loading