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
12 changes: 12 additions & 0 deletions .changeset/12622-form-date-picker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@object-ui/fields': patch
'@object-ui/i18n': patch
---

Record forms draw `date` and `datetime` fields with the platform date picker instead of the browser's native date input (objectui#12622).

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.
- **New language-pack keys** in all ten packs: `fields.datePicker.today`, `tomorrow`, `clear`, `openCalendar` and `invalid`.
27 changes: 27 additions & 0 deletions apps/console/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1407,6 +1407,33 @@ export default defineConfig({
includeDependenciesRecursively: false,
},
{ name: 'framework', test: /[\\/]packages[\\/](core|react|types)[\\/]/, priority: 80 },
//
// ## The record-form date picker leaves the eager line (objectui#12622)
//
// `ui-components` claims every `packages/fields/` module wherever it
// is reached from, so the picker's `import()` in `FORM_FACE_LOADERS`
// had no effect on chunking: the picker would have been fetched on
// every page load, including pages with no record form. This group
// takes the `widgets/date-picker/` directory only. The picker's
// imports from the rest of `packages/fields` (`DateField`,
// `nativeDateValue`, `useFieldTranslation`, ...) stay in
// `ui-components`. The new chunk is reached only through that
// `import()`, so the closure walk never enters it.
// `PER_CHUNK_MEMBERSHIP_CARVE_OUTS` in
// `scripts/check-eager-closure-budget.mjs` declares this split and
// checks it module by module. That check errors if this chunk ever
// joins the eager closure.
//
// `includeDependenciesRecursively: false` for the reason `types-zod`
// below gives: with the default left on, the group would pull the
// shared `widgets/` neighbours along an import, and `ui-components`
// would then import this chunk statically.
{
name: 'fields-date-picker',
test: /[\\/]packages[\\/]fields[\\/]src[\\/]widgets[\\/]date-picker[\\/]/,
priority: 82,
includeDependenciesRecursively: false,
},
{ name: 'ui-components', test: /[\\/]packages[\\/](components|fields)[\\/]/, priority: 80 },
{ name: 'ui-layout', test: /[\\/]packages[\\/]layout[\\/]/, priority: 80 },
{ name: 'infrastructure', test: /[\\/]packages[\\/](auth|permissions|tenant)[\\/]/, priority: 80 },
Expand Down
29 changes: 26 additions & 3 deletions packages/fields/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4483,8 +4483,9 @@ 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 same components `registerField` registers for
* forms), wrapped in `React.lazy` and cached per type — so a consumer can
* 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
* 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 @@ -4716,6 +4717,26 @@ 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 @@ -4724,7 +4745,9 @@ export function registerField(fieldType: string): void {
}

// Create lazy component
const LazyFieldWidget = React.lazy(loader);
const LazyFieldWidget = React.lazy(
FORM_FACE_LOADERS[fieldType as RegisteredFieldWidgetType] ?? 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
180 changes: 180 additions & 0 deletions packages/fields/src/widgets/date-picker/DatePickerField-12622.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#12622 — the record form's date picker.
*
* A `date` / `datetime` field in a record form drew the browser's native
* input, whose placeholder and digit order are the MACHINE's (`年/月/日` in an
* English console). The form face is now a text box in the display locale's
* format with a calendar popover, keyboard entry, clear, and today / tomorrow
* shortcuts. The value written is unchanged: `YYYY-MM-DD` for `date`, an ISO
* instant for `datetime`.
*/

import React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { I18nProvider } from '@object-ui/i18n';
import { DatePickerField, DateTimePickerField } from './DatePickerField';

afterEach(() => cleanup());

type Field = React.ComponentProps<typeof DatePickerField>['field'];
const FIELD = { name: 'close_date', type: 'date' } as Field;
const DT_FIELD = { name: 'remind_at', type: 'datetime' } as Field;

function inSession(language: string, node: React.ReactNode) {
return (
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }} persistLanguage={false}>
{node}
</I18nProvider>
);
}

const textBox = (container: HTMLElement) =>
container.querySelector('[data-slot="date-picker"] input') as HTMLInputElement;

const typeAndCommit = (input: HTMLInputElement, text: string) => {
fireEvent.change(input, { target: { value: text } });
fireEvent.blur(input);
};

describe('DatePickerField — the picker, not the native input (objectui#12622)', () => {
it('renders a text box in the display locale format, never input[type=date]', () => {
const { container } = render(<DatePickerField value="2026-10-16" onChange={() => {}} field={FIELD} />);
expect(container.querySelector('input[type="date"]')).toBeNull();
const input = textBox(container);
expect(input.type).toBe('text');
expect(input.value).toBe('10/16/2026');
expect(input.placeholder).toBe('MM/DD/YYYY');
expect(screen.getByTestId('date-picker-trigger')).toBeTruthy();
});

it('zh session: the same value in the zh format', () => {
const { container } = render(inSession('zh', <DatePickerField value="2026-10-16" onChange={() => {}} field={FIELD} />));
const input = textBox(container);
expect(input.value).toBe('2026/10/16');
expect(input.placeholder).toBe('YYYY/MM/DD');
});

it('typing a date in the locale format writes the ISO day', () => {
const onChange = vi.fn();
const { container } = render(<DatePickerField value="" onChange={onChange} field={FIELD} />);
typeAndCommit(textBox(container), '10/20/2026');
expect(onChange).toHaveBeenCalledWith('2026-10-20');
expect(textBox(container).value).toBe('10/20/2026');
});

it('Enter commits too', () => {
const onChange = vi.fn();
const { container } = render(<DatePickerField value="" onChange={onChange} field={FIELD} />);
const input = textBox(container);
fireEvent.change(input, { target: { value: '2026-10-20' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(onChange).toHaveBeenCalledWith('2026-10-20');
});

it('text naming no date is not written, and says so', () => {
const onChange = vi.fn();
const { container } = render(<DatePickerField value="2026-10-16" onChange={onChange} field={FIELD} />);
typeAndCommit(textBox(container), '02/30/2026');
expect(onChange).not.toHaveBeenCalled();
expect(textBox(container).getAttribute('aria-invalid')).toBe('true');
expect(screen.getByTestId('date-picker-notice').textContent).toContain('MM/DD/YYYY');
});

it('a calendar pick writes the ISO day', async () => {
const onChange = vi.fn();
render(<DatePickerField value="2026-10-16" onChange={onChange} field={FIELD} />);
fireEvent.click(screen.getByTestId('date-picker-trigger'));
const day = await waitFor(() => {
const el = document.body.querySelector(`button[data-day="${new Date(2026, 9, 20).toLocaleDateString()}"]`);
if (!el) throw new Error('calendar not open');
return el as HTMLButtonElement;
});
fireEvent.click(day);
expect(onChange).toHaveBeenCalledWith('2026-10-20');
});

it('the today and tomorrow shortcuts write those days', async () => {
const onChange = vi.fn();
render(<DatePickerField value="" onChange={onChange} field={FIELD} />);
const pad = (n: number) => String(n).padStart(2, '0');
const dayIn = (offset: number) => {
const d = new Date();
d.setDate(d.getDate() + offset);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
};
fireEvent.click(screen.getByTestId('date-picker-trigger'));
fireEvent.click(await screen.findByRole('button', { name: 'Today' }));
expect(onChange).toHaveBeenLastCalledWith(dayIn(0));
fireEvent.click(screen.getByTestId('date-picker-trigger'));
fireEvent.click(await screen.findByRole('button', { name: 'Tomorrow' }));
expect(onChange).toHaveBeenLastCalledWith(dayIn(1));
});

it('clear writes the empty value the native input wrote', () => {
const onChange = vi.fn();
render(<DatePickerField value="2026-10-16" onChange={onChange} field={FIELD} />);
fireEvent.click(screen.getByTestId('date-picker-clear'));
expect(onChange).toHaveBeenCalledWith('');
});

it('carries the host id, aria-required and disabled onto the text box', () => {
const { container } = render(
<DatePickerField value="" onChange={() => {}} field={FIELD} id="f-1" aria-required disabled />,
);
const input = textBox(container);
expect(input.id).toBe('f-1');
expect(input.getAttribute('aria-required')).toBe('true');
expect(input.disabled).toBe(true);
expect(screen.queryByTestId('date-picker-clear')).toBeNull();
expect((screen.getByTestId('date-picker-trigger') as HTMLButtonElement).disabled).toBe(true);
});

it('readonly is the DateField readonly face, unchanged', () => {
const { container } = render(<DatePickerField value="2026-10-16" onChange={() => {}} field={FIELD} readonly />);
expect(container.querySelector('input')).toBeNull();
expect(container.querySelector('[data-slot="date-picker"]')).toBeNull();
});
});

describe('DateTimePickerField — writes an ISO instant (objectui#12622)', () => {
it('shows the stored instant as the local wall clock in the locale format', () => {
const iso = new Date(2026, 9, 16, 14, 30).toISOString();
const { container } = render(<DateTimePickerField value={iso} onChange={() => {}} field={DT_FIELD} />);
expect(container.querySelector('input[type="datetime-local"]')).toBeNull();
expect(textBox(container).value).toBe('10/16/2026, 02:30 PM');
});

it('typing a date and time writes the ISO instant of that local wall clock', () => {
const onChange = vi.fn();
const { container } = render(<DateTimePickerField value="" onChange={onChange} field={DT_FIELD} />);
typeAndCommit(textBox(container), '10/16/2026 14:30');
const written = onChange.mock.calls[0][0] as string;
expect(written).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
expect(written).toBe(new Date(2026, 9, 16, 14, 30).toISOString());
});

it('a calendar pick keeps the value time', async () => {
const onChange = vi.fn();
const iso = new Date(2026, 9, 16, 9, 15).toISOString();
render(<DateTimePickerField value={iso} onChange={onChange} field={DT_FIELD} />);
fireEvent.click(screen.getByTestId('date-picker-trigger'));
const day = await waitFor(() => {
const el = document.body.querySelector(`button[data-day="${new Date(2026, 9, 20).toLocaleDateString()}"]`);
if (!el) throw new Error('calendar not open');
return el as HTMLButtonElement;
});
act(() => {
fireEvent.click(day);
});
expect(onChange).toHaveBeenCalledWith(new Date(2026, 9, 20, 9, 15).toISOString());
});
});
Loading
Loading