From 1d52721cbe465b7c49726d70ccafb957090045c2 Mon Sep 17 00:00:00 2001 From: BenGSchulz Date: Mon, 12 Oct 2026 15:13:14 -0700 Subject: [PATCH] fix(apollo-wind): read ISO date strings in date fields instead of crashing Hosts seed MetadataForm date and datetime fields from JSON, so values arrive as strings. DateTimePicker crashed on any string and DatePicker on unparseable ones, while z.coerce.date() guessed at numbers, years and locale strings and read date-only strings as UTC midnight. A new toDateValue helper accepts only a Date or an RFC 3339 date / date-time string (date-only as a local calendar day); anything else is no value. The form layer owns the parsing: FieldControl and QuickFormField parse before handing a Date to the pickers, and validation preprocesses with it, so an unrecognised value is absent rather than "Invalid date". The pickers keep their Date contract and only guard against non-Date input so they never throw. DateTimePicker also keeps milliseconds when a new day is picked. QuickFormField's own parseDateValue is replaced by toDateValue. Co-Authored-By: Claude Opus 5.5 --- .../components/forms/field-control.test.tsx | 31 +++++ .../src/components/forms/field-control.tsx | 5 +- .../components/forms/metadata-form.test.tsx | 120 +++++++++++++++++- .../forms/validation-converter.test.ts | 52 +++++++- .../components/forms/validation-converter.ts | 25 +++- .../src/components/ui/date-picker.test.tsx | 12 ++ .../src/components/ui/date-picker.tsx | 16 ++- .../components/ui/datetime-picker.test.tsx | 34 +++++ .../src/components/ui/datetime-picker.tsx | 20 ++- .../ui/quick-form-field/quick-form-field.tsx | 13 +- .../components/ui/quick-form-field/utils.ts | 34 +---- .../apollo-wind/src/lib/date-value.test.ts | 54 ++++++++ packages/apollo-wind/src/lib/date-value.ts | 26 ++++ 13 files changed, 374 insertions(+), 68 deletions(-) create mode 100644 packages/apollo-wind/src/lib/date-value.test.ts create mode 100644 packages/apollo-wind/src/lib/date-value.ts 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 26d780657e..fac50d9352 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 ece6fb754d..606a69c4d2 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 d43ad7e135..696c755957 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 2873028665..79091927ec 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 e84e331c1d..0f36a58be1 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 939701615a..a01cb9eed5 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 d7219713ed..9754fccd8f 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 a41a6c64e7..349fc0541a 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 0000000000..d82b1e58d4 --- /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 0000000000..29bb774bd2 --- /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; +}