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
31 changes: 31 additions & 0 deletions packages/apollo-wind/src/components/forms/field-control.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<FieldControl field={dateField} formField={binding('2026-09-23')} />);
expect(screen.getByText('September 23rd, 2026')).toBeInTheDocument();
});

it('shows an ISO date-time value as its local date and time', () => {
render(<FieldControl field={dateTimeField} formField={binding('2026-09-23T07:30')} />);
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(
<>
<FieldControl field={dateField} formField={binding(value)} />
<FieldControl field={dateTimeField} formField={binding(value)} />
</>
);
expect(screen.getByText('Pick a date')).toBeInTheDocument();
expect(screen.getByText('Pick a date and time')).toBeInTheDocument();
});
});
5 changes: 3 additions & 2 deletions packages/apollo-wind/src/components/forms/field-control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -371,7 +372,7 @@ export function FieldControl({
return (
<DatePicker
id={field.name}
value={formField.value as Date | undefined}
value={toDateValue(formField.value)}
onValueChange={formField.onChange}
disabled={disabled}
placeholder={field.placeholder}
Expand All @@ -388,7 +389,7 @@ export function FieldControl({
aria-invalid={invalid || undefined}
aria-describedby={describedById}
aria-errormessage={errorId}
value={formField.value as Date | undefined}
value={toDateValue(formField.value)}
onValueChange={formField.onChange}
disabled={disabled}
placeholder={field.placeholder}
Expand Down
120 changes: 119 additions & 1 deletion packages/apollo-wind/src/components/forms/metadata-form.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { format } from 'date-fns';
import { axe } from 'jest-axe';
import type React from 'react';
import { describe, expect, it, vi } from 'vitest';
import type { FormPlugin, FormSchema } from './form-schema';
import type { FormPlugin, FormSchema, ValidationConfig } from './form-schema';
import { MetadataForm } from './metadata-form';

// Basic form schema for tests
Expand Down Expand Up @@ -1215,3 +1216,120 @@ describe('string-list field', () => {
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<string, unknown>,
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(<MetadataForm schema={dateSchema({ day: value, at: value })} />);
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(<MetadataForm schema={dateSchema({ day: iso, at: iso })} />);
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(
<MetadataForm
schema={dateSchema({ day: '=vars.when', at: '2026' })}
onSubmit={handleSubmit}
/>
);

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(
<MetadataForm
schema={dateSchema(
{ day: '=vars.when', at: '2026-09-23T07:00' },
{ required: true, messages: { required: 'Choose a day' } }
)}
onSubmit={handleSubmit}
/>
);

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(
<MetadataForm
schema={{
id: 'conditional-date',
title: 'Conditional date',
initialData: { toggle: 'show', day: '=vars.when' },
sections: [
{
id: 's',
fields: [
{ name: 'toggle', type: 'text', label: 'Toggle' },
{
name: 'day',
type: 'date',
label: 'Day',
validation: { required: true },
rules: [
{
id: 'hide',
conditions: [{ when: 'toggle', is: 'hide' }],
effects: { visible: false },
},
],
},
],
},
],
}}
onSubmit={handleSubmit}
/>
);

await user.click(screen.getByRole('button', { name: /submit/i }));

expect(await screen.findByText('This field is required')).toBeInTheDocument();
expect(handleSubmit).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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');
Expand Down
25 changes: 19 additions & 6 deletions packages/apollo-wind/src/components/forms/validation-converter.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -73,11 +74,24 @@ export function validationConfigToZod(
) {
schema = (schema as z.ZodArray<z.ZodTypeAny>).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 {
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions packages/apollo-wind/src/components/ui/date-picker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<DatePicker value={value as unknown as Date} />);
expect(screen.getByText('Pick a date')).toBeInTheDocument();
});
});
16 changes: 9 additions & 7 deletions packages/apollo-wind/src/components/ui/date-picker.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -58,6 +58,8 @@ export const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(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.
Expand All @@ -77,8 +79,8 @@ export const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(f
'aria-label':
id || ariaLabelledBy
? undefined
: value
? `Selected date: ${format(value, 'PPP')}`
: date
? `Selected date: ${format(date, 'PPP')}`
: placeholder,
'aria-labelledby': ariaLabelledBy,
...validation.aria,
Expand All @@ -95,7 +97,7 @@ export const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(f
className={className}
placeholder={placeholder}
>
{value && <span className="truncate">{format(value, 'PPP')}</span>}
{date && <span className="truncate">{format(date, 'PPP')}</span>}
</InputGroupPopoverTrigger>
) : (
<PopoverTrigger asChild>
Expand All @@ -110,8 +112,8 @@ export const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(f
)}
>
<CalendarIcon />
{value ? (
format(value, 'PPP')
{date ? (
format(date, 'PPP')
) : (
<span className="text-foreground-muted">{placeholder}</span>
)}
Expand All @@ -121,7 +123,7 @@ export const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(f
<PopoverContent className="w-auto p-0" align={grouped ? 'start' : undefined}>
<Calendar
mode="single"
selected={value}
selected={date}
onSelect={onValueChange}
initialFocus
{...calendarProps}
Expand Down
Loading
Loading