diff --git a/.changeset/8194-fields-date-widget-convention.md b/.changeset/8194-fields-date-widget-convention.md new file mode 100644 index 0000000000..8fe0942574 --- /dev/null +++ b/.changeset/8194-fields-date-widget-convention.md @@ -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. diff --git a/packages/fields/src/__tests__/date-locale-channel.test.tsx b/packages/fields/src/__tests__/date-locale-channel.test.tsx index 92041acd94..c558680a94 100644 --- a/packages/fields/src/__tests__/date-locale-channel.test.tsx +++ b/packages/fields/src/__tests__/date-locale-channel.test.tsx @@ -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'; @@ -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). @@ -163,7 +200,12 @@ describe('zh session — every date branch renders Chinese (objectui#4468)', () 'zh', {}} 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( @@ -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', () => { @@ -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')); }); }); @@ -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', {}} 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')); }); }); diff --git a/packages/fields/src/__tests__/fields-date-widget-convention-8194.test.tsx b/packages/fields/src/__tests__/fields-date-widget-convention-8194.test.tsx new file mode 100644 index 0000000000..aabd7b40c8 --- /dev/null +++ b/packages/fields/src/__tests__/fields-date-widget-convention-8194.test.tsx @@ -0,0 +1,321 @@ +/** + * 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#8194 — the four readonly `date` WIDGET faces converge onto + * `formatDate`, following the maintainer's ruling A on objectui#7620. + * + * ── What was enumerated ────────────────────────────────────────────────── + * The card's population is defined by OMISSION — sites that render a date + * WITHOUT an options bag — so it was re-enumerated by mechanism rather than by + * spelling before anything was edited. Across the 78 non-test source files of + * `@object-ui/fields` the complete set of "a Date becomes user-visible text" + * mechanisms is: `toLocaleDateString` (7), `toLocaleTimeString` (2), the + * shared `formatDate` / `formatDateTime` family, and nothing else — no + * `Intl.DateTimeFormat`, no `toDateString`/`toUTCString`, no `date-fns` / + * `dayjs` / `luxon` / `moment`. `toLocaleString` (4) is number formatting. + * + * That yields SIX bare no-bag sites, two more than the card's four: + * + * 1. `widgets/DateField.tsx` readonly `date` widget ← fixed + * 2. `widgets/GridField.tsx` sub-grid `date` column ← fixed + * 3. `widgets/FormulaField.tsx` `return_type: 'date'` ← fixed + * 4. `widgets/lookupColumnDisplay.tsx` the `$date` fallback ← fixed + * 5. `widgets/DateField`'s sibling `DateTimeField.tsx` readonly ← NOT + * 6. `widgets/GridField.tsx`'s `datetime`/`time` branch ← NOT + * + * 5 and 6 are DATETIME faces. Their one home is `formatDateTime`, which has + * its own face vocabulary (`'compact'` from objectui#7443 versus the verbose + * default) — picking one of those is a display-convention decision the #7620 + * ruling does not reach, so they are recorded separately and left alone. The + * last describe block below is the fence that keeps them that way. + * + * ── What moved, measured in five locales ───────────────────────────────── + * `en-US`, for the SAME ISO date-only value: + * + * | path | current year | past year | + * | -------------------------------------- | ------------ | ------------- | + * | `date` field CELL (-> `formatDate`) | `Jul 4` | `Jul 4, 2024` | + * | the four widgets, BEFORE (no bag) | `7/4/2026` | `7/4/2024` | + * | the four widgets, AFTER (this PR) | `Jul 4` | `Jul 4, 2024` | + * + * ⚠️ This is a BIGGER move than objectui#7620's, and the difference is the one + * thing about this card a reviewer should look at twice. #7620's former face + * already asked for `{ year, month: 'short', day }`, so only the YEAR token + * moved and its past-year row was byte-identical. These four passed NO bag at + * all, i.e. `Intl`'s numeric default, so the WHOLE face changes and BOTH rows + * move, in every locale — `4.7.2026` becomes `4. Juli`, `2026/7/4` becomes + * `7月4日`, and the `ar` numeric form becomes `4 يوليو`. There is no + * must-not-change row here, which is why `FIXTURE VALIDITY` below asserts the + * disagreement in BOTH years rather than agreement in one. + * + * ── Directions ─────────────────────────────────────────────────────────── + * Reverting any of the four sites to its bare `toLocaleDateString(locale)` + * turns EVERY case in the first three describe blocks RED — both years, all + * five locales — because `FORMER_FACE` below is that removed spelling copied + * verbatim and every case asserts the render differs from it and equals the + * shared function. `FIXTURE VALIDITY` is green on both sides by construction: + * it measures the two formatters against each other, never the widgets, and + * exists so a fixture that silently stopped exercising the fork fails loudly + * instead of passing for free. The `en` literals are what stops a silent + * redesign of `formatDate`'s default face from sliding through with the + * shared-function comparisons still agreeing. + */ + +import React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { formatDate } from '@object-ui/core'; +import { I18nProvider, LocalizationProvider } from '@object-ui/i18n'; +import { DateField } from '../widgets/DateField'; +import { DateTimeField } from '../widgets/DateTimeField'; +import { FormulaField } from '../widgets/FormulaField'; +import { GridField } from '../widgets/GridField'; +import { renderLookupColumnValue } from '../widgets/lookupColumnDisplay'; + +/** The five locales this change was measured in. */ +const LOCALES = ['en', 'de', 'zh', 'ja', 'ar'] as const; + +/** + * A current-year date, read from the clock the same way `formatDate` reads it. + * July 4 is deliberate: a date-only ISO string parses as UTC midnight and the + * widgets format it in the runner's zone, so a January 1 or December 31 + * fixture would fall into the neighbouring year under a negative or positive + * offset and stop being a current-year date at all. + */ +const CURRENT_YEAR_DATE = `${new Date().getFullYear()}-07-04`; +/** The card's past-year value. */ +const PAST_YEAR_DATE = '2024-07-04'; +/** The `$date` wrapper carries an instant; 07:00Z keeps it on July 4 either way. */ +const asDollarDate = (iso: string) => ({ $date: `${iso}T07:00:00.000Z` }); + +/** + * The spelling REMOVED from all four sites, copied verbatim: a bare + * `toLocaleDateString(locale)` with no options bag. Every claim below is + * measured against THIS, not against a literal typed by hand. + */ +const FORMER_FACE = (iso: string, locale: string) => new Date(iso).toLocaleDateString(locale); + +/** + * `GridField`'s `date` branch never parsed the stored string — it split the + * `YYYY-MM-DD` into LOCAL calendar parts first, because `new Date('2026-06-17')` + * is UTC midnight and reading local components back out of it moves the day + * west of Greenwich (objectui#3569). Its former face is therefore that + * construction, not `FORMER_FACE`, and the two differ in exactly the zones + * that hazard is about. + */ +const FORMER_GRID_FACE = (iso: string, locale: string) => { + const [y, m, d] = iso.split('-').map(Number); + return new Date(y, m - 1, d).toLocaleDateString(locale); +}; + +/** The shared one home, default style — what every site should now render. */ +const shared = (iso: string, locale: string) => formatDate(iso, undefined, { locale }); + +function session(language: string, node: React.ReactNode) { + return render( + + {node} + , + ); +} + +/** Site 1 — the readonly `date` widget. */ +const renderDateField = (locale: string, value: string) => + session(locale, {}} field={{ type: 'date', name: 'when' } as any} readonly />) + .container.textContent ?? ''; + +/** Site 2 — the sub-grid readonly `date` column. */ +function renderGridCell(locale: string, value: string): string { + session( + locale, + {}} + readonly + field={{ columns: [{ name: 'when', label: 'When', type: 'date' as const }] } as any} + />, + ); + return gridCellText(); +} + +/** + * The readonly sub-grid paints an optional line-number `td` BEFORE the column + * cells, so `tbody td` picks up the row ordinal (`'1'`) rather than the value. + * The cell wanted is the last one — and the shape that makes that true is + * asserted here rather than assumed, so a future column added to this harness + * fails loudly instead of silently measuring the wrong `td`. + */ +function gridCellText(): string { + const cells = screen.getByTestId('line-items-readonly').querySelectorAll('tbody tr td'); + expect(cells).toHaveLength(2); + return cells[cells.length - 1].textContent ?? ''; +} + +/** Site 3 — a formula field declaring `return_type: 'date'`. */ +const renderFormula = (locale: string, value: unknown) => + session( + locale, + {}} field={{ type: 'formula', name: 'c', return_type: 'date' } as any} />, + ).container.textContent ?? ''; + +/** + * Site 4 — the lookup column plain-text fallback. Called directly: it is a + * pure function, and `descriptors: {}` is exactly the "no field descriptor" + * shape that drives a column into this branch. + */ +const renderLookupDollarDate = (locale: string, value: unknown) => + String(renderLookupColumnValue({ f: value }, { field: 'f' } as any, { descriptors: {}, displayLocale: locale })); + +const SITES: Array<[string, (locale: string, iso: string) => string, (iso: string, locale: string) => string]> = [ + ['DateField (readonly)', renderDateField, FORMER_FACE], + ['GridField (sub-grid date cell)', renderGridCell, FORMER_GRID_FACE], + ['FormulaField (return_type: date)', renderFormula, FORMER_FACE], + ['lookupColumnDisplay ($date fallback)', (l, iso) => renderLookupDollarDate(l, asDollarDate(iso)), FORMER_FACE], +]; + +afterEach(() => cleanup()); + +describe('FIXTURE VALIDITY — the premise every case below rests on', () => { + it.each(LOCALES)('%s — the removed bare spelling and formatDate disagree on a CURRENT-year date', (locale) => { + expect(shared(CURRENT_YEAR_DATE, locale)).not.toBe(FORMER_FACE(CURRENT_YEAR_DATE, locale)); + }); + + /** + * ⚠️ The line that differs from objectui#7620. There the past-year row was + * the must-not-change half; here the former face carried no `month: 'short'` + * either, so it moves too. Asserting the disagreement makes that explicit + * rather than leaving it as an unremarked consequence. + */ + it.each(LOCALES)('%s — and disagree on a PAST-year date as well (unlike objectui#7620)', (locale) => { + expect(shared(PAST_YEAR_DATE, locale)).not.toBe(FORMER_FACE(PAST_YEAR_DATE, locale)); + }); + + it('the current-year fixture really is the current year, and the past-year one is not', () => { + expect(new Date(CURRENT_YEAR_DATE).getFullYear()).toBe(new Date().getFullYear()); + expect(new Date(PAST_YEAR_DATE).getFullYear()).not.toBe(new Date().getFullYear()); + }); + + it('the year-dropping decision is what separates the two rows', () => { + expect(shared(CURRENT_YEAR_DATE, 'en')).not.toMatch(/\d{4}/); + expect(shared(PAST_YEAR_DATE, 'en')).toMatch(/\d{4}/); + }); +}); + +describe.each(SITES)('%s converges onto formatDate', (_name, renderSite, former) => { + it.each(LOCALES)('%s — the current-year render equals the shared function', (locale) => { + expect(renderSite(locale, CURRENT_YEAR_DATE)).toBe(shared(CURRENT_YEAR_DATE, locale)); + }); + + it.each(LOCALES)('%s — and no longer equals the removed bare spelling', (locale) => { + expect(renderSite(locale, CURRENT_YEAR_DATE)).not.toBe(former(CURRENT_YEAR_DATE, locale)); + }); + + it.each(LOCALES)('%s — the past-year render equals the shared function too', (locale) => { + expect(renderSite(locale, PAST_YEAR_DATE)).toBe(shared(PAST_YEAR_DATE, locale)); + }); + + it.each(LOCALES)('%s — and it too has left the removed bare spelling', (locale) => { + expect(renderSite(locale, PAST_YEAR_DATE)).not.toBe(former(PAST_YEAR_DATE, locale)); + }); +}); + +describe.each(SITES)('%s renders the exact face the #7620 ruling named, in en', (_name, renderSite) => { + /** + * ⚠️ One render per assertion group, held in a local. The sub-grid harness + * finds its table by `data-testid`, and a second `render()` in the same case + * leaves TWO of them mounted — `getByTestId` then throws "Found multiple" + * rather than measuring anything. `cleanup()` runs in `afterEach`, i.e. + * BETWEEN cases, not between renders inside one. + */ + it('a current-year date carries no year token', () => { + const text = renderSite('en', CURRENT_YEAR_DATE); + expect(text).toBe('Jul 4'); + expect(text).not.toMatch(/\d{4}/); + }); + + it('a past-year date still carries its year', () => { + expect(renderSite('en', PAST_YEAR_DATE)).toBe('Jul 4, 2024'); + }); + + it('neither row grows a time — these are date-only faces', () => { + expect(renderSite('en', CURRENT_YEAR_DATE)).not.toMatch(/\d\d:\d\d/); + cleanup(); + expect(renderSite('en', PAST_YEAR_DATE)).not.toMatch(/\d\d:\d\d/); + }); +}); + +describe('what an unreadable value does at each site', () => { + /** + * Three of the four sites inherit `formatDate`'s empty face for a value it + * cannot parse. That is a CONSEQUENCE of using the one home, not a second + * convention: they used to render the literal `Invalid Date`. + */ + it('DateField shows the shared empty face rather than "Invalid Date"', () => { + const text = renderDateField('en', 'not-a-date'); + expect(text).toBe('—'); + expect(text).not.toContain('Invalid Date'); + }); + + it('FormulaField shows the shared empty face rather than "Invalid Date"', () => { + expect(renderFormula('en', 'not-a-date')).toBe('—'); + }); + + it('the $date fallback shows the shared empty face rather than "Invalid Date"', () => { + expect(renderLookupDollarDate('en', { $date: 'not-a-date' })).toBe('—'); + }); + + /** + * GridField is the exception ON PURPOSE. Its `!ymd` guard runs BEFORE + * `formatDate` and still answers the raw stored string, because "showing the + * user what is actually stored beats hiding it" (objectui#3569). Converging + * the formatter must not quietly delete that. + */ + it('GridField still shows the raw stored value it cannot parse (objectui#3569)', () => { + expect(renderGridCell('en', 'not-a-date')).toBe('not-a-date'); + }); +}); + +describe('SCOPE FENCE — the two datetime sites this card did NOT converge', () => { + /** + * Enumerated alongside the four and deliberately left alone: their one home + * is `formatDateTime`, whose named faces (`'compact'` versus the verbose + * default) are a display-convention choice the objectui#7620 ruling does not + * reach. These two cases assert they still render the bare pair they always + * did, so this PR's boundary is measured rather than asserted in prose. A + * future ruling that converges them updates these two cases deliberately — + * that is the point of pinning the boundary. + */ + const DT = `${new Date().getFullYear()}-07-04T07:00:00.000Z`; + const formerDateTimePair = (locale: string) => { + const d = new Date(DT); + return `${d.toLocaleDateString(locale)} ${d.toLocaleTimeString(locale)}`; + }; + + it.each(LOCALES)('%s — readonly DateTimeField is unchanged by this card', (locale) => { + const { container } = session( + locale, + {}} field={{ type: 'datetime', name: 'at' } as any} readonly />, + ); + expect(container.textContent).toBe(formerDateTimePair(locale)); + }); + + it.each(LOCALES)('%s — the sub-grid datetime cell is unchanged by this card', (locale) => { + session( + locale, + {}} + readonly + field={{ columns: [{ name: 'at', label: 'At', type: 'datetime' as const }] } as any} + />, + ); + expect(gridCellText()).toBe(formerDateTimePair(locale)); + }); +}); diff --git a/packages/fields/src/datetime-widgets.test.tsx b/packages/fields/src/datetime-widgets.test.tsx index 58c344cdf8..a8fb9a8057 100644 --- a/packages/fields/src/datetime-widgets.test.tsx +++ b/packages/fields/src/datetime-widgets.test.tsx @@ -36,8 +36,19 @@ describe('Date/Time Widgets', () => { it('renders formatted date in readonly mode', () => { render(); + // Since objectui#8194 the readonly face is `formatDate`'s default + // style, not `Intl`'s bare numeric default: `Jan 1, 2023`, not + // `1/1/2023`. `2023-01-01` is a PAST year, so the year is still + // carried — the year only drops inside the CURRENT year, which is + // what `fields-date-widget-convention-8194.test.tsx` pins. + // No provider is mounted here, so the widget resolves the `'en'` + // last-resort tag; the expectation is built from that same tag. const date = new Date('2023-01-01'); - expect(screen.getByText(date.toLocaleDateString())).toBeInTheDocument(); + expect( + screen.getByText( + date.toLocaleDateString('en', { year: 'numeric', month: 'short', day: 'numeric' }), + ), + ).toBeInTheDocument(); expect(document.querySelector('input')).not.toBeInTheDocument(); }); diff --git a/packages/fields/src/widgets/DateField.tsx b/packages/fields/src/widgets/DateField.tsx index 8a472aed2e..bf0fdb27a3 100644 --- a/packages/fields/src/widgets/DateField.tsx +++ b/packages/fields/src/widgets/DateField.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Input, EmptyValue } from '@object-ui/components'; import { useDisplayLocale } from '@object-ui/i18n'; +import { formatDate } from '@object-ui/core'; import { FieldWidgetComponentProps } from './types.js'; import { toDomProps } from './toDomProps.js'; import { openNativePicker } from './openNativePicker.js'; @@ -16,7 +17,22 @@ export function DateField({ value, onChange, field, readonly, error, ...props }: // which is how a Chinese form ended up with an `8/11/2026` value in it. const locale = useDisplayLocale(); if (readonly) { - return value ? {new Date(value).toLocaleDateString(locale)} : ; + // The readonly face is `formatDate`'s DEFAULT style — the one home for the + // `date` display convention (objectui#8194, following the maintainer's + // ruling A on objectui#7620). It used to call `toLocaleDateString(locale)` + // with NO options bag, i.e. `Intl`'s numeric default (`7/4/2026`), so it + // never implemented the deliberate year-dropping decision `formatDate` + // documents — and this widget's readonly face is what `FieldEditWidget` + // renders in the grid / detail inline editors, right beside + // `DateCellRenderer`'s `Jul 4`. Two faces for one value, picked by which + // path the surface happened to take: #7620's fact pattern verbatim. + // A field that genuinely wants the year on every row is an explicit + // `format` style honoured by both paths, never a second option bag. + // + // `undefined` in the positional slot is how the published signature + // `formatDate(value, style?, options?)` asks for the default face; the + // positional argument outranks `options.style` (objectui#7745). + return value ? {formatDate(value, undefined, { locale })} : ; } const domProps = toDomProps(props); diff --git a/packages/fields/src/widgets/FormulaField.tsx b/packages/fields/src/widgets/FormulaField.tsx index 06311db95f..00e51528ed 100644 --- a/packages/fields/src/widgets/FormulaField.tsx +++ b/packages/fields/src/widgets/FormulaField.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { EmptyValue } from '@object-ui/components'; import { useDisplayLocale } from '@object-ui/i18n'; +import { formatDate } from '@object-ui/core'; import { FieldWidgetComponentProps } from './types.js'; /** @@ -25,7 +26,18 @@ export function FormulaField({ value, field, ...props }: FieldWidgetComponentPro } else if (returnType === 'boolean') { displayValue = value ? 'Yes' : 'No'; } else if (returnType === 'date') { - displayValue = new Date(value).toLocaleDateString(locale); + // `formatDate`'s DEFAULT style — the one home for the `date` display + // convention (objectui#8194, following the maintainer's ruling A on + // objectui#7620). This branch used to call `toLocaleDateString(locale)` + // with NO options bag, i.e. `Intl`'s numeric default (`7/4/2026`), so a + // formula returning a date rendered a face the shared function never + // produces — while the `date` field beside it showed `Jul 4`. Two faces + // for one value, kept in step by nothing. + // + // An unparseable computed value now reads `—` (the shared function's empty + // face) instead of the literal `Invalid Date`; that is a consequence of + // using the one home, not a second convention. + displayValue = formatDate(value, undefined, { locale }); } else { displayValue = String(value); } diff --git a/packages/fields/src/widgets/GridField.tsx b/packages/fields/src/widgets/GridField.tsx index 4e63281b51..f46c073b89 100644 --- a/packages/fields/src/widgets/GridField.tsx +++ b/packages/fields/src/widgets/GridField.tsx @@ -16,7 +16,7 @@ import { Label, } from '@object-ui/components'; import { Plus, Trash2, SlidersHorizontal, Maximize2, Copy, GripVertical } from 'lucide-react'; -import { resolveFieldRuleState } from '@object-ui/core'; +import { formatDate, resolveFieldRuleState } from '@object-ui/core'; import { useDisplayLocale } from '@object-ui/i18n'; import { LookupField } from './LookupField.js'; import { FileCell } from './FileField.js'; @@ -337,7 +337,10 @@ const isTemporal = (t?: string) => t === 'date' || t === 'datetime' || t === 'ti * - `date` — a calendar day. Formatted from its VERBATIM `YYYY-MM-DD` parts via * a local `Date`, never by parsing the stored string: `new Date('2026-06-17')` * is UTC midnight, so reading local calendar components back out of it moves - * the day to the 16th everywhere west of Greenwich. + * the day to the 16th everywhere west of Greenwich. That local `Date` is + * handed to `formatDate` as a `Date` INSTANCE, which the shared function uses + * verbatim — passing the raw string instead would re-introduce exactly the + * UTC-midnight parse this branch exists to avoid. * - `datetime` — an instant. Rendered as local day + local time, the same basis * `toDateTimeInputValue` uses for the editor, so the two never disagree (and * matching `DateTimeField`'s own read-only rendering). @@ -357,7 +360,19 @@ function temporalText(type: string | undefined, value: any, locale: string): str const ymd = toDateInputValue(value); if (!ymd) return raw; const [y, m, d] = ymd.split('-').map(Number); - return new Date(y, m - 1, d).toLocaleDateString(locale); + // `formatDate`'s DEFAULT style — the one home for the `date` display + // convention (objectui#8194, following the maintainer's ruling A on + // objectui#7620). This branch used to call `toLocaleDateString(locale)` + // with NO options bag, i.e. `Intl`'s numeric default (`7/4/2026`), so a + // sub-grid cell and a `date` field cell on the same screen rendered the + // same value two ways — the split #7620 ruled on, one surface over. + // Current-year values lose the year here now (`Jul 4`); past- and + // future-year values are byte-identical. + // + // The `!ymd` guard above still owns the unparseable case, so this branch + // never reaches `formatDate`'s `—`: an unreadable stored value keeps + // showing what is actually stored (objectui#3569). + return formatDate(new Date(y, m - 1, d), undefined, { locale }); } const dt = value instanceof Date ? value : new Date(raw); if (Number.isNaN(dt.getTime())) return raw; diff --git a/packages/fields/src/widgets/RecordPickerDialog.dateLocale.test.tsx b/packages/fields/src/widgets/RecordPickerDialog.dateLocale.test.tsx index ecb03ec571..99a1532e22 100644 --- a/packages/fields/src/widgets/RecordPickerDialog.dateLocale.test.tsx +++ b/packages/fields/src/widgets/RecordPickerDialog.dateLocale.test.tsx @@ -18,15 +18,27 @@ * if (val.$date) return new Date(val.$date).toLocaleDateString(); * * with no tag at all. `undefined` is not "the user's locale", it is the - * machine's — so this cell rendered `8/11/2026` on a `zh` console while every - * neighbouring date cell (fixed in PR #4512) rendered `2026/8/11`. + * machine's — so this cell rendered the machine's form on a `zh` console while + * every neighbouring date cell (fixed in PR #4512) rendered the Chinese one. * * ── Directions ─────────────────────────────────────────────────────────── - * Runner machine locale is `en-US`, so the `en` case is GREEN ON BOTH SIDES — - * the byte-identical pin, not evidence. The `zh` case goes red against - * unfixed code, and so does the precedence case: `de` (`11.8.2026`) differs - * from BOTH the machine form and the `zh` form, so it cannot pass by - * coincidence. + * The `zh` case goes red against unfixed code, and so does the precedence + * case: `de` differs from BOTH the `en` form and the `zh` form, so it cannot + * pass by coincidence. + * + * ⚠️ objectui#8194 amendment. This fallback now calls `formatDate` (default + * style) rather than a bare `toLocaleDateString`, so the FACE moved in every + * locale — `8/11/2026` → `Aug 11`, `2026/8/11` → `8月11日`, `11.8.2026` → + * `11. Aug.`. The `en` case is therefore no longer byte-identical across that + * change; what it still measures, and all this file ever claimed, is that the + * tag reaching `Intl` is the SESSION's and not the machine's. + * + * The expectations are built through `defaultDateFace()` rather than typed as + * literals because that face DROPS the year inside the current year: the same + * call renders `Aug 11` this year and `Aug 11, 2026` next January, and a + * literal would turn this locale file red for a reason that has nothing to do + * with locales. The year-dropping decision itself is pinned verbatim, against + * a frozen clock, in `__tests__/fields-date-widget-convention-8194.test.tsx`. * * This file mounts providers; the pure-function cases for the same card are * kept in `__tests__/date-formatter-residue-4272.test.ts` (objectui#4514). @@ -40,10 +52,26 @@ import { I18nProvider, LocalizationProvider } from '@object-ui/i18n'; import { RecordPickerDialog } from './RecordPickerDialog'; /** The exact expanded-Mongo shape the picker receives from the server. */ +const SIGNED_ON = new Date(2026, 7, 11, 0, 0, 0).toISOString(); const records = [ - { id: 'r1', name: 'Northwind', signed_on: { $date: new Date(2026, 7, 11, 0, 0, 0).toISOString() } }, + { id: 'r1', name: 'Northwind', signed_on: { $date: SIGNED_ON } }, ]; +/** + * The `date` DEFAULT face in `locale` — `formatDate`'s bag, spelled out, so + * these cases keep measuring the TAG rather than re-asserting the face. Mirrors + * the helper of the same name in `__tests__/date-locale-channel.test.tsx`. + */ +function defaultDateFace(locale: string): string { + const d = new Date(SIGNED_ON); + const sameYear = d.getFullYear() === new Date().getFullYear(); + return d.toLocaleDateString(locale, { + year: sameYear ? undefined : 'numeric', + month: 'short', + day: 'numeric', + }); +} + function makeDataSource() { return { find: vi.fn(async () => ({ data: records, total: records.length })) } as any; } @@ -97,28 +125,28 @@ describe('RecordPickerDialog — the $date fallback follows the display locale ( it('zh session renders the Chinese date form', async () => { renderSession('zh'); await waitFor(() => expect(screen.getByText('Northwind')).toBeInTheDocument()); - expect(bodyText()).toContain('2026/8/11'); - expect(bodyText()).not.toContain('8/11/2026'); + expect(bodyText()).toContain(defaultDateFace('zh')); + expect(bodyText()).not.toContain(defaultDateFace('en')); }); - /** PIN — the runner's machine locale is `en-US`, so this is green both sides. */ - it('en session output is byte-identical (must-not-change)', async () => { + it('en session renders the English date form', async () => { renderSession('en'); await waitFor(() => expect(screen.getByText('Northwind')).toBeInTheDocument()); - expect(bodyText()).toContain('8/11/2026'); + expect(bodyText()).toContain(defaultDateFace('en')); + expect(bodyText()).not.toContain(defaultDateFace('zh')); }); /** * `useDisplayLocale()` puts the TENANT's configured regional default above - * the active UI language. `de` is chosen because its form (`11.8.2026`) - * matches neither the machine's (`8/11/2026`) nor `zh`'s (`2026/8/11`), so - * this case is genuinely red before the fix instead of passing by accident. + * the active UI language. `de` is chosen because its form (`11. Aug.`) + * matches neither the `en` form (`Aug 11`) nor `zh`'s (`8月11日`), so this + * case is genuinely red before the fix instead of passing by accident. */ it('an explicit tenant locale outranks the active UI language', async () => { renderSession('zh', 'de'); await waitFor(() => expect(screen.getByText('Northwind')).toBeInTheDocument()); - expect(bodyText()).toContain('11.8.2026'); - expect(bodyText()).not.toContain('2026/8/11'); - expect(bodyText()).not.toContain('8/11/2026'); + expect(bodyText()).toContain(defaultDateFace('de')); + expect(bodyText()).not.toContain(defaultDateFace('zh')); + expect(bodyText()).not.toContain(defaultDateFace('en')); }); }); diff --git a/packages/fields/src/widgets/lookupColumnDisplay.tsx b/packages/fields/src/widgets/lookupColumnDisplay.tsx index 69975e7a52..390d3877dd 100644 --- a/packages/fields/src/widgets/lookupColumnDisplay.tsx +++ b/packages/fields/src/widgets/lookupColumnDisplay.tsx @@ -33,6 +33,7 @@ */ import React from 'react'; +import { formatDate } from '@object-ui/core'; import type { LookupColumnDef } from '@object-ui/types'; /** @@ -223,7 +224,19 @@ export function renderLookupColumnValue( // Handle MongoDB types / expanded references if (val.$numberDecimal) return String(Number(val.$numberDecimal)); if (val.$oid) return String(val.$oid); - if (val.$date) return new Date(val.$date).toLocaleDateString(displayLocale); + // `formatDate`'s DEFAULT style — the one home for the `date` display + // convention (objectui#8194, following the maintainer's ruling A on + // objectui#7620). This fallback used to call `toLocaleDateString` with NO + // options bag, i.e. `Intl`'s numeric default (`7/4/2026`), which made the + // split visible INSIDE this one function: a column that HAS a descriptor + // goes through `cellRenderer` above -> `DateCellRenderer` -> `formatDate` + // and renders `Jul 4`, while a column that has none landed here and + // rendered `7/4/2026`. Two faces for one value in one picker table, + // chosen by which path the cell happened to take. + // The value is handed over unchanged: `formatDate` parses it with the same + // `value instanceof Date ? value : new Date(value)` step this line used to + // do inline, so nothing about WHICH instant is read changes here. + if (val.$date) return formatDate(val.$date, undefined, { locale: displayLocale }); if (val.name || val.label) return String(val.name || val.label); return JSON.stringify(val); }