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
43 changes: 43 additions & 0 deletions .changeset/8194-fields-date-widget-convention.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
'@object-ui/fields': minor
---

One home for the `date` display convention in the readonly field widgets
(objectui#8194).

Four readonly `date` faces in `@object-ui/fields` called
`toLocaleDateString(locale)` with **no options bag at all** — `Intl`'s numeric
default — so they never implemented the year-dropping decision the shared
`formatDate` documents and every `date` CELL already follows. They now call
`formatDate` (default style):

- the readonly `DateField` (the form / detail face, and what `FieldEditWidget`
renders in the grid and detail inline editors),
- the sub-grid `GridField`'s readonly `date` column,
- a `FormulaField` declaring `return_type: 'date'`,
- the lookup picker's plain-text `$date` fallback (`lookupColumnDisplay`),
which sits in the same function as the descriptor path that already rendered
through `formatDate`.

**Visible change**: every one of those faces changes shape in every locale, in
every year — not only the year token. In `en-US` a date renders `Jul 4` this
year and `Jul 4, 2024` for a past year, where it used to render `7/4/2026` and
`7/4/2024`; in `de` `4. Juli` / `4. Juli 2024` for `4.7.2026` / `4.7.2024`; in
`zh` and `ja` `7月4日` / `2024年7月4日` for `2026/7/4` / `2024/7/4`; in `ar`
`4 يوليو` / `4 يوليو 2024`. Each now matches the `date` cell beside it. This is
a larger move than the sibling change in `@object-ui/components`
(objectui#7620), whose former face already asked for a short month and so only
lost its year token — these four passed no bag whatsoever.

A value the formatter cannot parse now reads `—` at three of the four sites
instead of the literal `Invalid Date`. The sub-grid keeps showing the raw
stored string for an unreadable value, unchanged (objectui#3569).

Untouched: the `datetime` readonly faces (`DateTimeField`, the sub-grid's
`datetime`/`time` branch). They are the same omission one type over, but their
home is `formatDateTime`, whose named faces are a separate display-convention
question; they are recorded on their own card rather than picked here.

A surface that genuinely wants the year on every row is an explicit `format`
style honoured by both paths, not a second option bag — the objectui#7620 /
objectui#7443 / objectui#4576 lesson, one surface over.
63 changes: 57 additions & 6 deletions packages/fields/src/__tests__/date-locale-channel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@
*
* `已逾期`/`Overdue Nd` is likewise green on both sides: it never used the
* broken channel, and this file pins that the fix did not disturb it.
*
* ⚠️ objectui#8194 amendment. The `date` WIDGET faces in this file (readonly
* `DateField`, the sub-grid `date` column, a `date`-returning `FormulaField`)
* used to render `Intl`'s bare numeric default — `8/11/2026` / `2026/8/11` —
* because they passed NO options bag. They now render `formatDate`'s default
* face, the one home for the `date` display convention. That moves the `en`
* output too, so those literals were replaced by `defaultDateFace()` below:
* this file's subject is WHICH TAG reaches `Intl`, and expressing the
* expectation through the shared bag keeps that subject measurable without
* re-asserting the face. The `datetime` cases here are untouched — they were
* NOT part of #8194 and still render two bare `toLocale*` calls.
*/

import { describe, it, expect, afterEach } from 'vitest';
Expand Down Expand Up @@ -72,6 +83,32 @@ function daysFromNow(n: number): string {
*/
const FIXED_INSTANT = new Date(2026, 7, 11, 0, 0, 0).toISOString();

/**
* The `date` DEFAULT face in `locale` — `formatDate`'s bag, spelled out.
*
* Every `date` surface in this file renders through `formatDate`'s default
* style since objectui#8194, and that face DROPS the year inside the current
* year on purpose. So the expected string cannot be a literal here: `Aug 11`
* and `Aug 11, 2026` are the same call in different calendar years, and a
* hard-coded literal would turn this locale-channel file red on a January 1st
* for a reason that has nothing to do with locales.
*
* This is the idiom the "absolute fallback beyond the ±7-day window" case
* below already used for the same reason; #8194 only widened its reach. The
* year-dropping decision itself is pinned VERBATIM, against a frozen clock,
* in `fields-date-widget-convention-8194.test.tsx` — that claim belongs
* there, this file's claim is that the tag reaching `Intl` is the session's.
*/
function defaultDateFace(value: string | Date, locale: string): string {
const d = value instanceof Date ? value : new Date(value);
const sameYear = d.getFullYear() === new Date().getFullYear();
return d.toLocaleDateString(locale, {
year: sameYear ? undefined : 'numeric',
month: 'short',
day: 'numeric',
});
}

/**
* A session: the UI language the user picked, plus the tenant's regional
* default (usually absent — the state the card was measured in).
Expand Down Expand Up @@ -163,7 +200,12 @@ describe('zh session — every date branch renders Chinese (objectui#4468)', ()
'zh',
<DateField value={FIXED_INSTANT} onChange={() => {}} field={dateField('start_date')} readonly />,
);
expect(container.textContent).toContain('2026/8/11');
// Since objectui#8194 this widget renders `formatDate`'s default face, so
// the zh form is `8月11日` in the current year and `2026年8月11日` after —
// both Chinese, which is the claim. The `en` form is asserted absent so
// the case cannot pass on a machine-locale render.
expect(container.textContent).toContain(defaultDateFace(FIXED_INSTANT, 'zh'));
expect(container.textContent).not.toContain(defaultDateFace(FIXED_INSTANT, 'en'));
cleanup();

const dt = renderSession(
Expand Down Expand Up @@ -201,8 +243,8 @@ describe('zh session — every date branch renders Chinese (objectui#4468)', ()
/>,
);
const table = screen.getByTestId('line-items-readonly');
expect(table.textContent).toContain('2026/6/17');
expect(table.textContent).not.toContain('6/17/2026');
expect(table.textContent).toContain(defaultDateFace('2026-06-17T00:00:00.000Z', 'zh'));
expect(table.textContent).not.toContain(defaultDateFace('2026-06-17T00:00:00.000Z', 'en'));
});

it('a formula field returning a date', () => {
Expand All @@ -214,8 +256,8 @@ describe('zh session — every date branch renders Chinese (objectui#4468)', ()
field={{ type: 'formula', name: 'computed_on', return_type: 'date' } as any}
/>,
);
expect(container.textContent).toContain('2026/8/11');
expect(container.textContent).not.toContain('8/11/2026');
expect(container.textContent).toContain(defaultDateFace(FIXED_INSTANT, 'zh'));
expect(container.textContent).not.toContain(defaultDateFace(FIXED_INSTANT, 'en'));
});
});

Expand Down Expand Up @@ -252,12 +294,21 @@ describe('en session — output is byte-identical (must-not-change)', () => {
expect(container.textContent).toContain('12:00 am');
});

/**
* ⚠️ This case is NO LONGER byte-identical across objectui#8194 — the widget
* moved from `Intl`'s bare numeric default (`8/11/2026`) onto `formatDate`'s
* default face (`Aug 11` in the current year). It stays in this describe
* block because what it measures is unchanged: the `en` session renders the
* `en` face. The move itself is pinned in
* `fields-date-widget-convention-8194.test.tsx`.
*/
it('read-only DateField', () => {
const { container } = renderSession(
'en',
<DateField value={FIXED_INSTANT} onChange={() => {}} field={dateField('start_date')} readonly />,
);
expect(container.textContent).toContain('8/11/2026');
expect(container.textContent).toContain(defaultDateFace(FIXED_INSTANT, 'en'));
expect(container.textContent).not.toContain(defaultDateFace(FIXED_INSTANT, 'zh'));
});
});

Expand Down
Loading
Loading