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
2 changes: 1 addition & 1 deletion .changeset/12622-form-date-picker.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@ Record forms draw `date` and `datetime` fields with the platform date picker ins
The picker is a text box in the display locale's format (`10/16/2026` in English, `2026/10/16` in Chinese), with a calendar popover, keyboard entry, a clear button, and Today / Tomorrow shortcuts. The native input took its placeholder and digit order from the machine, so an English console could show a Chinese machine's placeholder.

- **The value written is unchanged.** A `date` field writes `YYYY-MM-DD` and a `datetime` field writes an ISO instant, as the native inputs did. Clearing writes an empty value, as before. Typed text that names no date is not written: the box is marked invalid and a notice names the expected format.
- **Where it applies.** The form registration of `field:date` / `field:datetime` (what `registerField` / `registerAllFields` register) takes the picker. The inline editors (`FieldEditWidget`, the detail inline editor, the kanban required-fields dialog), `getLazyFieldWidget` (the action-param and bulk-action dialogs), and a form without `@object-ui/fields` registered keep the native input. The read-only face is unchanged.
- **Where it applies.** The form registration of `field:date` / `field:datetime` (what `registerField` / `registerAllFields` register) takes the picker, and so does `getLazyFieldWidget` (the action-param and bulk-action dialogs, objectui#12642). The inline editors (`FieldEditWidget`, the detail inline editor, the kanban required-fields dialog) and a form without `@object-ui/fields` registered keep the native input. The read-only face is unchanged.
- **New language-pack keys** in all ten packs: `fields.datePicker.today`, `tomorrow`, `clear`, `openCalendar` and `invalid`.
13 changes: 13 additions & 0 deletions .changeset/12642-dialog-date-picker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@object-ui/fields': patch
'@object-ui/app-shell': patch
---

Action-param and bulk-action dialogs draw `date` and `datetime` params with the platform date picker, the same face record forms draw since objectui#12622 (objectui#12642).

The native input took its placeholder and digit order from the machine's locale, not the console's. Now one field type has one face in forms and dialogs.

- **Where it applies.** Everything that resolves a field widget through `getLazyFieldWidget` or `registerField` / `registerAllFields`: the record form, `ActionParamDialog`, the grid's `BulkActionDialog`, and the console's form page. The inline editors (`FieldEditWidget`, the detail inline editor) and the list filters keep the native input.
- **The value written is unchanged.** A `date` param writes `YYYY-MM-DD` and a `datetime` param writes an ISO instant, as the native inputs did.
- **The Action designer's preview** draws a date-picker shape for `date` / `datetime` params, matching the dialog.
- **No public surface changes.** No type, prop, export or language-pack key is added or removed (Clause-②: no). `getLazyFieldWidget('date' | 'datetime')` returns another component with the same props.
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,10 @@ describe('value shapes — what the shared widgets emit is what the page submits
it('submits a date string, a number, a multiselect array and a lookup id', async () => {
await renderForm();

// `date` is the platform picker (objectui#12642): its text box commits on
// blur, and an ISO day reads in every locale.
fireEvent.change(screen.getByLabelText('Due'), { target: { value: '2026-03-04' } });
fireEvent.blur(screen.getByLabelText('Due'));
fireEvent.change(screen.getByLabelText('Quantity'), { target: { value: '7' } });

const tags = screen.getByRole('group', { name: 'Tags' });
Expand Down
24 changes: 17 additions & 7 deletions packages/app-shell/src/views/ActionParamDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -454,11 +454,18 @@ describe('ActionParamDialog — shared field-widget rendering (ADR-0059)', () =>
expect(await screen.findByRole('combobox')).toBeTruthy();
});

it('renders a date param through the shared DateField (native date input)', async () => {
// objectui#12642: the dialog draws the platform date picker the record form
// draws (one face per field type), not the browser's native input, whose
// placeholder and digit order are the machine's. The value is unchanged.
it('renders a date param through the platform date picker and resolves the YYYY-MM-DD day', async () => {
const resolve = openDialog([def({ name: 'due', type: 'date' })]);
const input = await screen.findByLabelText('Param One');
expect(input.getAttribute('type')).toBe('date');
fireEvent.change(input, { target: { value: '2026-07-19' } });
const input = (await screen.findByLabelText('Param One')) as HTMLInputElement;
expect(input.closest('[data-slot="date-picker"]'), 'date picker not rendered').not.toBeNull();
expect(input.type).toBe('text');
expect(input.placeholder).toBe('MM/DD/YYYY');
expect(document.querySelector('input[type="date"]')).toBeNull();
fireEvent.change(input, { target: { value: '07/19/2026' } });
fireEvent.blur(input);
confirm();
await waitFor(() => expect(resolve).toHaveBeenCalledWith({ due: '2026-07-19' }));
});
Expand Down Expand Up @@ -611,9 +618,12 @@ describe('emitted value-shape parity (contract-tied render proofs, #2714)', () =
it('datetime param emits a zoned ISO instant the platform validator accepts', async () => {
const param = def({ name: 'when', type: 'datetime' });
const resolve = openDialog([param]);
const input = await screen.findByLabelText('Param One');
expect(input.getAttribute('type')).toBe('datetime-local');
fireEvent.change(input, { target: { value: '2026-07-20T14:30' } });
const input = (await screen.findByLabelText('Param One')) as HTMLInputElement;
// objectui#12642: the platform picker, typed in the display locale's format.
expect(input.closest('[data-slot="date-picker"]'), 'date picker not rendered').not.toBeNull();
expect(document.querySelector('input[type="datetime-local"]')).toBeNull();
fireEvent.change(input, { target: { value: '07/20/2026 14:30' } });
fireEvent.blur(input);
confirm();
await waitFor(() => expect(resolve).toHaveBeenCalled());
const emitted = resolve.mock.calls[0][0].when;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
* ## Why the expectations are hand-declared AND cross-checked
*
* `EXPECTED` below is read out of the runtime by a human: `datetime` renders
* `DateTimeField`, so its mock must be a datetime control, not a text box. If
* the platform date picker (`DateTimePickerField`, objectui#12642), so its mock
* must be a date picker, not a plain text box. If
* the test DERIVED each expectation from the same resolver the preview calls,
* it could never catch the preview calling the resolver wrongly — the pin would
* assert the implementation against itself. So the per-case expectation is
Expand Down Expand Up @@ -85,13 +86,14 @@ const OFFERED: readonly string[] = PARAM_TYPE_OPTS.map((o) => o.value);
* `input:*` is the DOM `type` of the rendered `<input>`; `record-picker` is the
* disabled combobox the preview mocks a `LookupField` with, the one that pops
* a dialog; `select` is the shared `Select`'s trigger, the combobox that pops
* none, which `SelectField` draws too (objectui#11865).
* none, which `SelectField` draws too (objectui#11865); `date-picker` is the
* text box inside a `data-slot="date-picker"` wrapper, the shape the platform
* date picker draws for `date` and `datetime` (objectui#12642).
*/
type MockControl =
| 'input:text'
| 'input:number'
| 'input:date'
| 'input:datetime-local'
| 'date-picker'
| 'input:checkbox'
| 'textarea'
| 'select'
Expand All @@ -109,8 +111,8 @@ const MOCK_FOR_RUNTIME_WIDGET: Readonly<Record<string, MockControl>> = {
number: 'input:number',
boolean: 'input:checkbox',
select: 'select',
date: 'input:date',
datetime: 'input:datetime-local',
date: 'date-picker',
datetime: 'date-picker',
lookup: 'record-picker',
};

Expand Down Expand Up @@ -146,8 +148,8 @@ const CASES: readonly Case[] = [
expected: 'select',
why: 'SelectField renders an EMPTY picker with a placeholder — not a text box',
},
{ spelling: 'date', expected: 'input:date', why: 'DateField' },
{ spelling: 'datetime', expected: 'input:datetime-local', why: 'DateTimeField' },
{ spelling: 'date', expected: 'date-picker', why: 'DatePickerField, the platform date picker' },
{ spelling: 'datetime', expected: 'date-picker', why: 'DateTimePickerField, the platform date picker' },
{
spelling: 'lookup',
extra: { reference: 'account' },
Expand Down Expand Up @@ -212,6 +214,7 @@ function drawnControl(container: HTMLElement): MockControl {
if (tag === 'select') return 'select';
if (tag === 'textarea') return 'textarea';
if (tag === 'button') return el.getAttribute('aria-haspopup') === 'dialog' ? 'record-picker' : 'select';
if (el.closest('[data-slot="date-picker"]')) return 'date-picker';
return `input:${(el as HTMLInputElement).type}` as MockControl;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import * as React from 'react';
import {
AlertTriangle,
Bot,
CalendarDays,
Code2,
Eye,
Globe,
Expand Down Expand Up @@ -661,9 +662,15 @@ function renderFieldMock(p: ActionParam, fieldLabel: string, locale?: string): R
case 'number':
return inputMock('number');
case 'date':
return inputMock('date');
case 'datetime':
return inputMock('datetime-local');
// The dialog draws the platform date picker for both (objectui#12642):
// a text box with a calendar button, not the browser's native input.
return (
<div className="relative" data-slot="date-picker">
<input type="text" className={`${cls} pr-6`} placeholder={placeholder} value={value} readOnly />
<CalendarDays className="absolute right-1.5 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" aria-hidden />
</div>
);
case 'lookup':
// `LookupField` is a combobox that opens a record picker — mocked with
// the same disabled-combobox shape the designer's other pickers use, so
Expand Down
40 changes: 12 additions & 28 deletions packages/fields/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4315,8 +4315,15 @@ const fieldWidgetMap = {
'number': () => import('./widgets/NumberField.js').then(m => ({ default: m.NumberField })),
'boolean': () => import('./widgets/BooleanField.js').then(m => ({ default: m.BooleanField })),
'select': () => import('./widgets/SelectField.js').then(m => ({ default: m.SelectField })),
'date': () => import('./widgets/DateField.js').then(m => ({ default: m.DateField })),
'datetime': () => import('./widgets/DateTimeField.js').then(m => ({ default: m.DateTimeField })),
// The platform date picker (objectui#12622, objectui#12642): a text box in
// the display locale's format with a calendar popover, for every consumer of
// this map — the record form (`registerField`) and the action-param and
// bulk-action dialogs (`getLazyFieldWidget`) alike, so a field type has one
// face. It writes what the native input wrote: `YYYY-MM-DD` for `date`, an
// ISO instant for `datetime`. Its read-only face is `DateField` /
// `DateTimeField`'s own.
'date': () => import('./widgets/date-picker/DatePickerField.js').then(m => ({ default: m.DatePickerField })),
'datetime': () => import('./widgets/date-picker/DatePickerField.js').then(m => ({ default: m.DateTimePickerField })),
'time': () => import('./widgets/TimeField.js').then(m => ({ default: m.TimeField })),

// Contact fields
Expand Down Expand Up @@ -4483,9 +4490,8 @@ const lazyFieldWidgets = new Map<string, React.ComponentType<any>>();

/**
* Lazily-loaded form field widget for a field type. Shares the exact loaders
* of {@link fieldWidgetMap} (the components `registerField` registers for
* forms, except the form-only faces in `FORM_FACE_LOADERS`), wrapped in
* `React.lazy` and cached per type — so a consumer can
* of {@link fieldWidgetMap} (the same components `registerField` registers for
* forms), wrapped in `React.lazy` and cached per type — so a consumer can
* render any form-supported field type without eagerly bundling every widget.
* Render inside a `<Suspense>` boundary. Unknown types resolve to the `text`
* widget via {@link resolveFormWidgetType}.
Expand Down Expand Up @@ -4717,26 +4723,6 @@ export const FIELD_WIDGET_LABELLING: Record<
'permission-ref': 'control',
};

/**
* The FORM face of a field type, where it differs from the widget
* {@link fieldWidgetMap} loads (objectui#12622).
*
* A record form draws `date` / `datetime` with the platform date picker: a
* text box in the display locale's format, a calendar popover, clear, and
* today / tomorrow shortcuts. The ruling scopes it to record forms, so only
* the registry registration (what a form resolves `field:date` /
* `field:datetime` to) takes it. {@link getLazyFieldWidget} (the action-param
* and bulk-action dialogs) and the inline editors (`FieldEditWidget`, which
* imports `DateField` / `DateTimeField` directly) keep the native input.
*
* The value written is the native input's: `YYYY-MM-DD` for `date`, an ISO
* instant for `datetime` (see `widgets/date-picker/DatePickerField.tsx`).
*/
const FORM_FACE_LOADERS: Partial<Record<RegisteredFieldWidgetType, FieldWidgetLoader>> = {
date: () => import('./widgets/date-picker/DatePickerField.js').then(m => ({ default: m.DatePickerField })),
datetime: () => import('./widgets/date-picker/DatePickerField.js').then(m => ({ default: m.DateTimePickerField })),
};

export function registerField(fieldType: string): void {
const loader = fieldWidgetLoaderByKey[fieldType];
if (!loader) {
Expand All @@ -4745,9 +4731,7 @@ export function registerField(fieldType: string): void {
}

// Create lazy component
const LazyFieldWidget = React.lazy(
FORM_FACE_LOADERS[fieldType as RegisteredFieldWidgetType] ?? loader,
);
const LazyFieldWidget = React.lazy(loader);

// Register with field namespace. No LAYOUT wrapper — the form renderer owns
// label/description/spacing. The only thing wrapped is the metadata carrier:
Expand Down
19 changes: 10 additions & 9 deletions packages/fields/src/widgets/date-picker/DatePickerField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,13 @@ import {
import { useFieldTranslation } from '../useFieldTranslation.js';

/**
* The record form's date and date-time controls (objectui#12622).
* The `date` and date-time controls of the form field widgets
* (objectui#12622, objectui#12642).
*
* The registry's `date` / `datetime` widgets — what a record form resolves
* `field:date` / `field:datetime` to — draw the platform picker instead of the
* browser's native input: a text box in the display locale's format (keyboard
* `fieldWidgetMap`'s `date` / `datetime` widgets — what a record form resolves
* `field:date` / `field:datetime` to, and what `getLazyFieldWidget` hands the
* action-param and bulk-action dialogs — draw the platform picker instead of
* the browser's native input: a text box in the display locale's format (keyboard
* entry), a calendar popover (the same `Calendar` the calendar view's date
* popover opens, localized by the same resolver), a clear button, and
* today / tomorrow shortcuts.
Expand All @@ -51,11 +53,10 @@ import { useFieldTranslation } from '../useFieldTranslation.js';
*
* - The read-only face is `DateField` / `DateTimeField`'s own, rendered as is.
* - The inline editors (`FieldEditWidget`: the grid / detail inline editors and
* the kanban required-fields dialog) import `DateField` / `DateTimeField`
* directly and keep the native input; this card's ruling is the record form.
* - `getLazyFieldWidget` (the action-param and bulk-action dialogs) loads the
* `date` / `datetime` widgets of `fieldWidgetMap`, which stay `DateField` /
* `DateTimeField`: only `registerField` takes this face.
* the kanban required-fields dialog; plugin-detail's `InlineFieldInput`)
* import `DateField` / `DateTimeField` directly and keep the native input.
* A popover inside a grid cell or a filter row is a different interaction,
* measured on its own (objectui#12642).
* - A form whose `field:date` widget is not registered (no
* `@object-ui/fields` loaded) falls back to the form renderer's own
* `<input type={inputType}>`, with the `inputType` `ObjectForm` maps.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,12 @@ async function formPick(day: string): Promise<unknown> {
<FormDateWidget field={{ name: 'due_date', type: 'date' }} value={STORED} onChange={onChange} />
</React.Suspense>,
);
await waitFor(() => expect(container.querySelector('input[type="date"]')).not.toBeNull());
fireEvent.change(container.querySelector('input[type="date"]')!, { target: { value: day } });
// The form's `date` widget is the platform date picker (objectui#12642): a
// text box that commits on blur, and an ISO day reads in every locale.
const box = '[data-slot="date-picker"] input';
await waitFor(() => expect(container.querySelector(box)).not.toBeNull());
fireEvent.change(container.querySelector(box)!, { target: { value: day } });
fireEvent.blur(container.querySelector(box)!);
expect(onChange).toHaveBeenCalledTimes(1);
cleanup();
return onChange.mock.calls[0][0];
Expand Down
45 changes: 34 additions & 11 deletions packages/plugin-grid/src/__tests__/bulkActionDialogParams.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* many → array patch → label preview) and the date widget. Since the ADR-0059
* migration (#3064) params render through the SHARED form field widgets
* (`@object-ui/fields` via `bulkParamToField`), so these tests now assert the
* form-widget DOM (multiselect toggle chips, native date input) — and the new
* form-widget DOM (multiselect toggle chips, the platform date picker) — and the new
* lookup suite pins the #3064 contract itself: no eager candidate prefetch,
* fetch errors surfaced with a Retry affordance, and no failure caching
* (reopening the picker refetches).
Expand Down Expand Up @@ -120,15 +120,15 @@ describe('BulkActionDialog — multi-select param produces an array patch', () =
});
});

describe('BulkActionDialog — date param renders a date picker', () => {
it('renders a native date input rather than a free-text box', async () => {
// objectui#12642: a `date` / `datetime` param draws the platform date picker
// the record form draws (one face per field type), not the browser's native
// input, whose placeholder and digit order are the machine's. The patch each
// row receives is unchanged: `YYYY-MM-DD` for `date`, an ISO instant for
// `datetime`.
describe('BulkActionDialog — date params render the platform date picker', () => {
async function runWith(param: Record<string, unknown>, typed: string) {
const ds = makeDataSource();
const def: any = {
name: 'set_due',
label: 'Set due date',
operation: 'update',
params: [{ name: 'due', label: 'Due', type: 'date' }],
};
const def: any = { name: 'set_it', label: 'Set it', operation: 'update', params: [param] };
render(
<BulkActionDialog
def={def}
Expand All @@ -140,9 +140,32 @@ describe('BulkActionDialog — date param renders a date picker', () => {
/>,
);
// Radix Dialog portals to document.body, and the widget loads lazily.
await waitFor(() => {
expect(document.querySelector('input[type="date"]')).toBeInTheDocument();
const input = await waitFor(() => {
const el = document.querySelector('[data-slot="date-picker"] input');
if (!el) throw new Error('date picker not rendered');
return el as HTMLInputElement;
});
expect(input.type).toBe('text');
expect(input.placeholder).toMatch(/^MM\/DD\/YYYY/);
expect(document.querySelector('input[type="date"], input[type="datetime-local"]')).toBeNull();
fireEvent.change(input, { target: { value: typed } });
fireEvent.blur(input);
const next = screen.getByRole('button', { name: 'Next' });
await waitFor(() => expect(next).toBeEnabled());
fireEvent.click(next);
fireEvent.click(await screen.findByRole('button', { name: 'Run' }));
await waitFor(() => expect(ds.update).toHaveBeenCalledTimes(1));
return ds.update.mock.calls[0];
}

it('a `date` param writes the YYYY-MM-DD day', async () => {
const call = await runWith({ name: 'due', label: 'Due', type: 'date', required: true }, '10/20/2026');
expect(call).toEqual(['thing', 'r1', { due: '2026-10-20' }]);
});

it('a `datetime` param writes the ISO instant of the typed local wall clock', async () => {
const call = await runWith({ name: 'at', label: 'At', type: 'datetime', required: true }, '10/20/2026 14:30');
expect(call).toEqual(['thing', 'r1', { at: new Date(2026, 9, 20, 14, 30).toISOString() }]);
});
});

Expand Down
Loading
Loading