diff --git a/packages/apollo-wind/src/components/forms/field-control.test.tsx b/packages/apollo-wind/src/components/forms/field-control.test.tsx index 26d780657..fac50d935 100644 --- a/packages/apollo-wind/src/components/forms/field-control.test.tsx +++ b/packages/apollo-wind/src/components/forms/field-control.test.tsx @@ -105,3 +105,34 @@ describe('FIELD_CONTROL_GEOMETRY', () => { expect(formField.onChange).toHaveBeenCalledWith(null); }); }); + +// Hosts seed date fields from JSON, so the bound value can be any string. +describe('FieldControl date values', () => { + const dateField: FieldMetadata = { name: 'day', type: 'date', label: 'Day' }; + const dateTimeField: FieldMetadata = { name: 'at', type: 'datetime', label: 'At' }; + + it('shows an ISO date-only value as that local calendar day', () => { + render(); + expect(screen.getByText('September 23rd, 2026')).toBeInTheDocument(); + }); + + it('shows an ISO date-time value as its local date and time', () => { + render(); + expect(screen.getByText(/September 23rd, 2026 at 07:30/)).toBeInTheDocument(); + }); + + it.each([ + ['an empty string', ''], + ['a locale string', 'Wed Sep 23 2026 00:00:00 GMT-0700 (Pacific Daylight Time)'], + ['an expression', '=vars.when'], + ])('shows the placeholders for %s instead of throwing', (_label, value) => { + render( + <> + + + + ); + expect(screen.getByText('Pick a date')).toBeInTheDocument(); + expect(screen.getByText('Pick a date and time')).toBeInTheDocument(); + }); +}); diff --git a/packages/apollo-wind/src/components/forms/field-control.tsx b/packages/apollo-wind/src/components/forms/field-control.tsx index ece6fb754..606a69c4d 100644 --- a/packages/apollo-wind/src/components/forms/field-control.tsx +++ b/packages/apollo-wind/src/components/forms/field-control.tsx @@ -24,6 +24,7 @@ import { import { Slider } from '@/components/ui/slider'; import { Switch } from '@/components/ui/switch'; import { Textarea } from '@/components/ui/textarea'; +import { toDateValue } from '@/lib/date-value'; import { joinIds } from './aria-ids'; import type { FieldMetadata, FieldOption, FieldType, SliderFieldMetadata } from './form-schema'; import { LiteralValueContext } from './literal-value-context'; @@ -371,7 +372,7 @@ export function FieldControl({ return ( { expect(await axe(container)).toHaveNoViolations(); }); }); + +// Hosts seed date fields from JSON, so a value can be any string. +describe('MetadataForm untyped date values', () => { + const dateSchema = ( + initialData: Record, + validation?: ValidationConfig + ): FormSchema => ({ + id: 'dates', + title: 'Dates', + initialData, + sections: [ + { + id: 's', + fields: [ + { name: 'day', type: 'date', label: 'Day', validation }, + { name: 'at', type: 'datetime', label: 'At', validation }, + ], + }, + ], + }); + + it.each([ + ['an empty string', ''], + ['a locale string', 'Wed Sep 23 2026 00:00:00 GMT-0700 (Pacific Daylight Time)'], + ['an expression', '=vars.when'], + ])('shows the placeholders for %s instead of throwing', (_label, value) => { + render(); + expect(screen.getByText('Pick a date')).toBeInTheDocument(); + expect(screen.getByText('Pick a date and time')).toBeInTheDocument(); + }); + + it('shows an ISO date-time value on both controls', () => { + const iso = '2026-09-23T07:00:00.000Z'; + render(); + const instant = new Date(iso); + expect(screen.getByText(format(instant, 'PPP'))).toBeInTheDocument(); + expect( + screen.getByText(`${format(instant, 'PPP')} at ${format(instant, 'HH:mm')}`) + ).toBeInTheDocument(); + }); + + it('submits an optional field holding an unrecognised value as absent', async () => { + const user = userEvent.setup(); + const handleSubmit = vi.fn(); + render( + + ); + + await user.click(screen.getByRole('button', { name: /submit/i })); + + await waitFor(() => expect(handleSubmit).toHaveBeenCalled()); + expect(handleSubmit.mock.calls[0][0]).toMatchObject({ day: undefined, at: undefined }); + }); + + it('gives a required field holding an unrecognised value the required message', async () => { + const user = userEvent.setup(); + const handleSubmit = vi.fn(); + render( + + ); + + await user.click(screen.getByRole('button', { name: /submit/i })); + + expect(await screen.findByText('Choose a day')).toBeInTheDocument(); + expect(handleSubmit).not.toHaveBeenCalled(); + }); + + it('treats an unrecognised value as empty for a conditionally required field', async () => { + const user = userEvent.setup(); + const handleSubmit = vi.fn(); + render( + + ); + + await user.click(screen.getByRole('button', { name: /submit/i })); + + expect(await screen.findByText('This field is required')).toBeInTheDocument(); + expect(handleSubmit).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/apollo-wind/src/components/forms/validation-converter.test.ts b/packages/apollo-wind/src/components/forms/validation-converter.test.ts index d43ad7e13..696c75595 100644 --- a/packages/apollo-wind/src/components/forms/validation-converter.test.ts +++ b/packages/apollo-wind/src/components/forms/validation-converter.test.ts @@ -75,7 +75,7 @@ describe('validationConfigToZod', () => { it('creates date schema for date field', () => { const schema = validationConfigToZod(undefined, 'date'); expect(schema.safeParse(new Date()).success).toBe(true); - expect(schema.safeParse('2024-01-01').success).toBe(true); // coerce + expect(schema.safeParse('2024-01-01').success).toBe(true); }); it('creates date schema for datetime field', () => { @@ -206,6 +206,56 @@ describe('validationConfigToZod', () => { }); }); + describe('date values', () => { + const unrecognised = [ + '', + '2026', + 'Wed Sep 23 2026 00:00:00 GMT-0700 (Pacific Daylight Time)', + '=vars.when', + 1758610800123, + ]; + + it.each(['date', 'datetime'] as const)('reads an unrecognised %s value as absent', (type) => { + for (const value of unrecognised) { + expect(validationConfigToZod(undefined, type).safeParse(value)).toEqual({ + success: true, + data: undefined, + }); + expect(validationConfigToZod({ required: false }, type).safeParse(value).success).toBe( + true + ); + } + }); + + it.each([ + 'date', + 'datetime', + ] as const)('gives an unrecognised required %s value the required message', (type) => { + const schema = validationConfigToZod( + { required: true, messages: { required: 'Pick a date' } }, + type + ); + for (const value of [...unrecognised, undefined, null]) { + const result = schema.safeParse(value); + expect(result.success).toBe(false); + expect(result.error?.issues[0].message).toBe('Pick a date'); + } + }); + + it('parses an ISO date-time to a Date that keeps its milliseconds', () => { + const result = validationConfigToZod({ required: true }, 'datetime').safeParse( + '2026-09-23T07:00:00.123Z' + ); + expect(result.success).toBe(true); + expect((result.data as Date).getTime()).toBe(Date.UTC(2026, 8, 23, 7, 0, 0, 123)); + }); + + it('parses a date-only string to local midnight', () => { + const result = validationConfigToZod({ required: true }, 'date').safeParse('2026-09-23'); + expect(result.data).toEqual(new Date(2026, 8, 23)); + }); + }); + describe('required validation', () => { it('makes field required when config.required is true', () => { const schema = validationConfigToZod({ required: true }, 'text'); diff --git a/packages/apollo-wind/src/components/forms/validation-converter.ts b/packages/apollo-wind/src/components/forms/validation-converter.ts index 287302866..79091927e 100644 --- a/packages/apollo-wind/src/components/forms/validation-converter.ts +++ b/packages/apollo-wind/src/components/forms/validation-converter.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { toDateValue } from '@/lib/date-value'; import type { CustomValueType, FieldType, ValidationConfig, ValueModeId } from './form-schema'; import { type CodecContext, isDecodedEmpty, type ValueModeCodec } from './value-modes'; @@ -41,7 +42,7 @@ export function validationConfigToZod( // If no config, return optional base schema if (!config) { - return schema.nullish(); + return withDateParsing(schema.nullish(), fieldType); } // Apply type-specific constraints @@ -73,11 +74,24 @@ export function validationConfigToZod( ) { schema = (schema as z.ZodArray).min(1, requiredMessage); } - return applyCustomExpression(schema, config); + // An unrecognised date reaches the schema as undefined, so missing is its only failure. + if (isDateType(fieldType)) { + schema = z.date({ error: requiredMessage }); + } + return withDateParsing(applyCustomExpression(schema, config), fieldType); } // `null` too: it is a cleared field's value, as react-hook-form expects one to be. - return applyCustomExpression(schema, config).nullish(); + return withDateParsing(applyCustomExpression(schema, config).nullish(), fieldType); +} + +function isDateType(fieldType: FieldType): boolean { + return fieldType === 'date' || fieldType === 'datetime'; +} + +// Outside the optional wrapper: an unrecognised value must read as absent, not fail `z.date()`. +function withDateParsing(schema: z.ZodTypeAny, fieldType: FieldType): z.ZodTypeAny { + return isDateType(fieldType) ? z.preprocess(toDateValue, schema) : schema; } export interface ModeAwareSchemaOptions { @@ -181,10 +195,9 @@ function getBaseSchemaForType( return z.array(z.string()); case 'date': - return z.coerce.date(); - case 'datetime': - return z.coerce.date(); + // Parsed by `withDateParsing`; coercion would turn numbers and locale strings into dates. + return z.date(); case 'file': // File validation is typically handled separately by the file input diff --git a/packages/apollo-wind/src/components/ui/date-picker.test.tsx b/packages/apollo-wind/src/components/ui/date-picker.test.tsx index e84e331c1..0f36a58be 100644 --- a/packages/apollo-wind/src/components/ui/date-picker.test.tsx +++ b/packages/apollo-wind/src/components/ui/date-picker.test.tsx @@ -361,3 +361,15 @@ describe('DateRangePicker in an input group', () => { expect(screen.getByText('Jan 05, 2026 - Jan 09, 2026')).toBeInTheDocument(); }); }); + +// Untyped callers can still hand over something other than a Date. +describe('DatePicker non-Date values', () => { + it.each([ + ['a string', '2026-09-23T07:00:00.000Z'], + ['a number', 1758610800123], + ['an Invalid Date', new Date(Number.NaN)], + ])('shows the placeholder for %s instead of throwing', (_label, value) => { + render(); + expect(screen.getByText('Pick a date')).toBeInTheDocument(); + }); +}); diff --git a/packages/apollo-wind/src/components/ui/date-picker.tsx b/packages/apollo-wind/src/components/ui/date-picker.tsx index 939701615..a01cb9eed 100644 --- a/packages/apollo-wind/src/components/ui/date-picker.tsx +++ b/packages/apollo-wind/src/components/ui/date-picker.tsx @@ -1,6 +1,6 @@ 'use client'; -import { format } from 'date-fns'; +import { format, isValid } from 'date-fns'; import { CalendarIcon } from 'lucide-react'; import * as React from 'react'; import type { DateRange } from 'react-day-picker'; @@ -58,6 +58,8 @@ export const DatePicker = React.forwardRef(f ) { const generatedId = React.useId(); const validationId = errorId ?? `${id ?? `date-picker-${generatedId.replace(/:/g, '')}`}-error`; + // Trusts the `Date` type only as far as not throwing: anything else is no value. + const date = value instanceof Date && isValid(value) ? value : undefined; // Inside an InputGroup the trigger is the group's control: the group draws the box, and the // panel anchors to it. @@ -77,8 +79,8 @@ export const DatePicker = React.forwardRef(f 'aria-label': id || ariaLabelledBy ? undefined - : value - ? `Selected date: ${format(value, 'PPP')}` + : date + ? `Selected date: ${format(date, 'PPP')}` : placeholder, 'aria-labelledby': ariaLabelledBy, ...validation.aria, @@ -95,7 +97,7 @@ export const DatePicker = React.forwardRef(f className={className} placeholder={placeholder} > - {value && {format(value, 'PPP')}} + {date && {format(date, 'PPP')}} ) : ( @@ -110,8 +112,8 @@ export const DatePicker = React.forwardRef(f )} > - {value ? ( - format(value, 'PPP') + {date ? ( + format(date, 'PPP') ) : ( {placeholder} )} @@ -121,7 +123,7 @@ export const DatePicker = React.forwardRef(f { await waitFor(() => expect(measure).toHaveBeenCalled()); }); }); + +// Untyped callers can still hand over something other than a Date. +describe('DateTimePicker non-Date values', () => { + it.each([ + ['a string', '2026-09-23T07:00:00.000Z'], + ['a number', 1758610800123], + ['an Invalid Date', new Date(Number.NaN)], + ])('shows the placeholder for %s instead of throwing', (_label, value) => { + render(); + expect(screen.getByText('Pick a date and time')).toBeInTheDocument(); + }); +}); + +describe('DateTimePicker picking a day', () => { + it('keeps the milliseconds when a new day is picked', async () => { + const user = userEvent.setup(); + const handleChange = vi.fn(); + render( + + ); + + await user.click(screen.getByRole('button')); + await user.click(screen.getByRole('button', { name: /15th/ })); + + const selected = handleChange.mock.calls[0][0] as Date; + expect(selected.getDate()).toBe(15); + expect([ + selected.getHours(), + selected.getMinutes(), + selected.getSeconds(), + selected.getMilliseconds(), + ]).toEqual([14, 30, 15, 123]); + }); +}); diff --git a/packages/apollo-wind/src/components/ui/datetime-picker.tsx b/packages/apollo-wind/src/components/ui/datetime-picker.tsx index d7219713e..9754fccd8 100644 --- a/packages/apollo-wind/src/components/ui/datetime-picker.tsx +++ b/packages/apollo-wind/src/components/ui/datetime-picker.tsx @@ -1,4 +1,4 @@ -import { format } from 'date-fns'; +import { format, isValid } from 'date-fns'; import { CalendarIcon, Clock } from 'lucide-react'; import * as React from 'react'; import { Button } from '@/components/ui/button'; @@ -64,23 +64,20 @@ export const DateTimePicker = React.forwardRef(value); + // Trusts the `Date` type only as far as not throwing: anything else is no value. + const date = value instanceof Date && isValid(value) ? value : undefined; + const [selectedDate, setSelectedDate] = React.useState(date); // A native time input only accepts 24-hour text; use12Hour affects the display alone. - const [timeValue, setTimeValue] = React.useState(value ? format(value, 'HH:mm') : ''); + const [timeValue, setTimeValue] = React.useState(date ? format(date, 'HH:mm') : ''); // Re-seed when the parent changes the value, compared by time so a fresh Date with the // same instant (or our own change echoed back) is not a change. - const valueTime = value?.getTime(); + const valueTime = date?.getTime(); const [seededTime, setSeededTime] = React.useState(valueTime); if (!Object.is(valueTime, seededTime)) { setSeededTime(valueTime); - if (value === undefined) { - setSelectedDate(undefined); - setTimeValue(''); - } else if (!Number.isNaN(valueTime)) { - setSelectedDate(value); - setTimeValue(format(value, 'HH:mm')); - } + setSelectedDate(date); + setTimeValue(date ? format(date, 'HH:mm') : ''); } const handleDateSelect = (date: Date | undefined) => { @@ -91,6 +88,7 @@ export const DateTimePicker = React.forwardRef editableOnValueChange?.(date ? toDateOnlyString(date) : '')} initialFocus /> @@ -300,7 +295,7 @@ export function QuickFormField({ // than the date branch's Calendar popover. Stored as an ISO string. editableOnValueChange?.(date ? date.toISOString() : '')} onOpenChange={(open) => { if (!open) onValueBlur?.(); diff --git a/packages/apollo-wind/src/components/ui/quick-form-field/utils.ts b/packages/apollo-wind/src/components/ui/quick-form-field/utils.ts index a41a6c64e..349fc0541 100644 --- a/packages/apollo-wind/src/components/ui/quick-form-field/utils.ts +++ b/packages/apollo-wind/src/components/ui/quick-form-field/utils.ts @@ -1,3 +1,4 @@ +import { toDateValue } from '@/lib/date-value'; import type { QuickFormFieldOption } from './types'; export const DEFAULT_SELECT_OPTIONS: QuickFormFieldOption[] = [ @@ -15,36 +16,7 @@ export function parseListValue(value: string): string[] { } } -const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/; - -/** - * Parses a date field's stored value, returning undefined for empty or invalid input. - * - * Date-only strings (`YYYY-MM-DD`) are parsed as a local date instead of going through - * `new Date(string)` directly -- the latter treats date-only strings as UTC midnight, - * which rolls over to the previous day once formatted in a negative-UTC-offset - * timezone. Full ISO timestamps (which already carry explicit time/zone info) go - * through `new Date` as-is. - */ -export function parseDateValue(value: string): Date | undefined { - if (DATE_ONLY_PATTERN.test(value)) { - const [year, month, day] = value.split('-').map(Number); - const date = new Date(year, month - 1, day); - // new Date() normalizes out-of-range components (e.g. month 13, day 40) - // into a different valid date instead of rejecting them -- reject - // anything that didn't round-trip back to the requested year/month/day. - const isValid = - !Number.isNaN(date.getTime()) && - date.getFullYear() === year && - date.getMonth() === month - 1 && - date.getDate() === day; - return isValid ? date : undefined; - } - const date = new Date(value); - return Number.isNaN(date.getTime()) ? undefined : date; -} - -/** Formats a Date as a local `YYYY-MM-DD` string, the inverse of parseDateValue's date-only path. */ +/** Formats a Date as a local `YYYY-MM-DD` string, which `toDateValue` reads back as a local date. */ export function toDateOnlyString(date: Date): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); @@ -54,7 +26,7 @@ export function toDateOnlyString(date: Date): string { /** Formats a date field's value for display, falling back to the raw value if it isn't a valid date. */ export function formatDateValue(value: string): string { - const date = parseDateValue(value); + const date = toDateValue(value); return date ? date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }) : value; diff --git a/packages/apollo-wind/src/lib/date-value.test.ts b/packages/apollo-wind/src/lib/date-value.test.ts new file mode 100644 index 000000000..d82b1e58d --- /dev/null +++ b/packages/apollo-wind/src/lib/date-value.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { toDateValue } from './date-value'; + +describe('toDateValue', () => { + it('reads a UTC date-time with milliseconds as that instant', () => { + expect(toDateValue('2026-09-23T07:00:00.123Z')?.getTime()).toBe( + Date.UTC(2026, 8, 23, 7, 0, 0, 123) + ); + }); + + it('honours an explicit offset', () => { + expect(toDateValue('2026-09-23T07:00:00+02:00')?.getTime()).toBe( + Date.UTC(2026, 8, 23, 5, 0, 0) + ); + }); + + it('reads a date-time without an offset as local time', () => { + expect(toDateValue('2026-09-23T07:00')?.getTime()).toBe(new Date(2026, 8, 23, 7, 0).getTime()); + }); + + it('truncates fractional seconds to milliseconds', () => { + expect(toDateValue('2026-09-23T07:00:00.1239Z')?.getUTCMilliseconds()).toBe(123); + }); + + it('reads a date-only string as local midnight on that calendar day', () => { + const date = toDateValue('2026-09-23'); + expect([date?.getFullYear(), date?.getMonth(), date?.getDate(), date?.getHours()]).toEqual([ + 2026, 8, 23, 0, + ]); + }); + + it('returns a valid Date as the same instant', () => { + const date = new Date(2026, 8, 23, 7, 0, 0, 123); + expect(toDateValue(date)?.getTime()).toBe(date.getTime()); + }); + + it.each([ + ['an empty string', ''], + ['null', null], + ['undefined', undefined], + ['a number', 2026], + ['a year string', '2026'], + ['a reduced year', '26'], + ['a reduced year-month', '202609'], + ['an epoch-milliseconds string', '1758610800123'], + ['a locale string', 'Wed Sep 23 2026 00:00:00 GMT-0700 (Pacific Daylight Time)'], + ['an expression', '=vars.when'], + ['an out-of-range day', '2026-02-30'], + ['an Invalid Date', new Date(Number.NaN)], + ['an object', {}], + ])('returns undefined for %s', (_label, value) => { + expect(toDateValue(value)).toBeUndefined(); + }); +}); diff --git a/packages/apollo-wind/src/lib/date-value.ts b/packages/apollo-wind/src/lib/date-value.ts new file mode 100644 index 000000000..29bb774bd --- /dev/null +++ b/packages/apollo-wind/src/lib/date-value.ts @@ -0,0 +1,26 @@ +import { isValid, parseISO } from 'date-fns'; + +// JSON Schema's `format: date` and `format: date-time` (RFC 3339), with seconds optional. A +// missing offset is local time. `parseISO` alone also accepts reduced forms such as "26". +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; +const DATE_TIME_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?$/; + +/** + * Reads a date or date-time value from untyped input, such as JSON-sourced form data. + * + * Accepts a valid `Date` or an RFC 3339 date / date-time string; a date-only string is a local + * calendar date. Anything else is no value: the standard parsers disagree on numbers, digit-only + * and locale strings, so they are not guessed at. + */ +export function toDateValue(value: unknown): Date | undefined { + if (value instanceof Date) { + if (isValid(value)) return value; + } else if ( + typeof value === 'string' && + (DATE_PATTERN.test(value) || DATE_TIME_PATTERN.test(value)) + ) { + const date = parseISO(value); + if (isValid(date)) return date; + } + return undefined; +}