From 426a4d3a84dc728a16506041fcf75a47cf70a710 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 08:00:24 +0000 Subject: [PATCH] fix(plugin-grid): the record-detail date fallback threads the display locale (#4541) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `renderRecordDetail`'s type-inference fallback called `formatDate(value)` with no options, so `formatDate` handed `Intl` an `undefined` tag — the MACHINE's locale, neither of the repo's two locale channels. On a zh console that one cell rendered `Mar 15, 2024` while every neighbouring date cell rendered `2024年8月15日`. This was the third `formatDate` site in the file; #4272 (PR #4544) ruled its plugin-grid surface to "ONLY the two date-cell call sites" (the two passing `'short'`), so this one was filed rather than fixed there. Pure consumption, not plumbing: the component already reads `useDisplayLocale()` at component level (PR #4544) and `renderRecordDetail` is a plain arrow in the component body that already closes over `tenantCurrency` from the same scope, so the call site simply gains `{ locale: displayLocale }`. Not memoized, so there is no dependency array to keep in step. Red-first: predicted 2 red + 1 green-both-sides, matched exactly. `en` and the runner's `en-US` are byte-identical on this branch, so the en case is a labelled PIN, not evidence. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 --- .changeset/record-detail-date-locale-4541.md | 33 +++ packages/plugin-grid/src/ObjectGrid.tsx | 8 +- .../__tests__/recordDetailDateLocale.test.tsx | 191 ++++++++++++++++++ 3 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 .changeset/record-detail-date-locale-4541.md create mode 100644 packages/plugin-grid/src/__tests__/recordDetailDateLocale.test.tsx diff --git a/.changeset/record-detail-date-locale-4541.md b/.changeset/record-detail-date-locale-4541.md new file mode 100644 index 000000000..5f3932db6 --- /dev/null +++ b/.changeset/record-detail-date-locale-4541.md @@ -0,0 +1,33 @@ +--- +'@object-ui/plugin-grid': patch +--- + +ObjectGrid's record-detail date fallback follows the display locale +(objectui#4541). + +`renderRecordDetail`'s type-inference fallback rendered date-like values with +a bare `formatDate(value)` — no options at all. `formatDate` then handed `Intl` +an `undefined` tag, and `undefined` is not "the user's locale", it is the +**machine's**, which is neither of the repo's two locale channels. On a `zh` +console that one cell rendered `Mar 15, 2024` while every neighbouring date +cell rendered `2024年3月15日`. + +This was the third `formatDate` site in the file. objectui#4272 (PR #4544) +ruled its plugin-grid surface to "ONLY the two date-cell call sites" — the two +that pass `'short'` in the mobile card view — and this one was never among +them, so it was filed rather than fixed there. + +The fix is pure consumption, not plumbing: the component already reads +`useDisplayLocale()` at component level (landed in PR #4544), and +`renderRecordDetail` is a plain arrow in the component body that already closes +over `tenantCurrency` from that same scope, so the call site simply gains +`{ locale: displayLocale }`. No hook was added, and the function is not +memoized, so there is no dependency array to keep in step. + +One resolver everywhere, as before: `useDisplayLocale()` (tenant regional +default → active UI language → `'en'`). English output is byte-identical — the +runner's `en-US` and `en` agree on this branch — and the two `'short'` cells +PR #4544 threaded are untouched. + +`patch` rather than `minor`: the package's own `.d.ts` files are byte-identical +across the change, so this is module-local (the objectui#4496 precedent). diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index fcdd1d8f2..8f5470d2a 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -2784,9 +2784,13 @@ export const ObjectGrid: React.FC = ({ if (typeof value === 'boolean') { return {value ? t('grid.yes') : t('grid.no')}; } - // Detect date-like values + // Detect date-like values. The tag comes from `useDisplayLocale()` like + // every other date channel: passing no options at all handed `Intl` an + // `undefined` tag, i.e. the MACHINE's locale, which is neither of the + // repo's two locale channels — so this cell rendered `Mar 15, 2024` on a + // zh console while its neighbours rendered `2024年3月15日` (objectui#4541). if (typeof value === 'string' && !isNaN(Date.parse(value)) && (key.includes('date') || key.includes('_at') || key.includes('time'))) { - return {formatDate(value)}; + return {formatDate(value, undefined, { locale: displayLocale })}; } // Detect currency-like fields by name const currencyFields = ['amount', 'price', 'total', 'revenue', 'cost', 'value', 'budget', 'salary']; diff --git a/packages/plugin-grid/src/__tests__/recordDetailDateLocale.test.tsx b/packages/plugin-grid/src/__tests__/recordDetailDateLocale.test.tsx new file mode 100644 index 000000000..e6e4870ce --- /dev/null +++ b/packages/plugin-grid/src/__tests__/recordDetailDateLocale.test.tsx @@ -0,0 +1,191 @@ +/** + * 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#4541 — ObjectGrid's record-detail date fallback rendered the + * MACHINE's locale. + * + * `renderRecordDetail` → `renderFieldValue` is type-aware first: when the + * object schema gives the key a `type` with a registered cell renderer, that + * renderer draws the value. Everything else falls through to an *inference* + * fallback, whose date branch did: + * + * if (typeof value === 'string' && !isNaN(Date.parse(value)) && + * (key.includes('date') || key.includes('_at') || key.includes('time'))) + * return (span)...{formatDate(value)}...(/span); + * + * with no `options` at all. `formatDate` then hands `Intl` an `undefined` tag, + * and `undefined` is not "the user's locale" — it is the **machine's**, which + * is neither of the repo's two locale channels. So on a `zh` console this one + * cell rendered `Mar 15, 2024` while every neighbouring date cell (#4468 / + * PR #4512, and the two mobile-card `'short'` cells in #4272 / PR #4544) + * rendered the Chinese form. + * + * This was the **third** `formatDate` site in the file and was deliberately + * out of #4272's ruled scope ("ONLY the two date-cell call sites"): the two it + * named both pass `'short'` and shift by a constant −121 line offset, which + * this site does not fit. #4544 filed it here rather than fixing it. + * + * The component already reads `useDisplayLocale()` at component level (landed + * in PR #4544 for the two `'short'` cells), and `renderRecordDetail` is a + * plain arrow declared in the component body — it already closes over + * `tenantCurrency` from the same scope two branches below. So the fix is pure + * consumption: one call site gaining `{ locale: displayLocale }`, no plumbing, + * no dependency array (the function is not memoized). + * + * ── Reaching the branch at all (the #4544 naming lesson, restated) ──────── + * PR #4544's grid suite had to name its columns like dates because the card + * view's `classify()` routes by `dateKeys` substrings. The record-detail path + * has the *same* shape of trap in a different place, and BOTH halves matter: + * + * 1. The fixture passes `data: { provider: 'value', items: ROWS }` and NO + * `dataSource`, so `objectSchema` stays null and `fieldDef` is undefined + * for every key. That is what skips the `getCellRenderer` branch and + * drives the value into the inference fallback where this bug lives. + * Give the field a schema `type: 'date'` instead and DateCellRenderer + * draws it — a different, already-fixed path — and this suite would pin + * nothing. + * 2. The keys must be *named* like dates. `close_date` (matches `date`) and + * `signed_at` (matches `_at`) cover two of the branch's three spellings; + * a key such as `closed_on` would fall past the branch to the plain + * `String()` tail and render no date at all. + * + * Both fixture keys are deliberately non-system (`created_at` / `updated_at` + * and friends are `SYSTEM_MANAGED_FIELD_NAMES`, which would divert them into + * the muted meta section that renders through `String()`, not `formatDate`). + * Neither key is a grid column: the value therefore appears ONLY in the detail + * panel, so no table cell can satisfy these assertions on another path's + * behalf. + * + * ── Why assertions are scoped to the panel, not the container ───────────── + * The overlay renders through a portal, so it is not inside the `container` + * that `render()` returns; and a container-wide `toContain` could in principle + * be satisfied by an ordinary grid cell rendering the same date through the + * already-localized `DateCellRenderer`. Reading + * `getByTestId('record-detail-panel').textContent` pins the assertion to the + * one render path this card is about. + * + * ── Directions (measured on the runner, not presumed) ───────────────────── + * node v22.22.2, TZ=UTC, machine locale `en-US`. For `formatDate`'s default + * branch (`{year:'numeric',month:'short',day:'numeric'}`): + * + * undefined → 'Mar 15, 2024' en → 'Mar 15, 2024' + * zh → '2024年3月15日' de → '15. März 2024' + * + * `en` and the machine's `en-US` are IDENTICAL here, so the `en` case below is + * **GREEN ON BOTH SIDES** — it is the byte-identical must-not-change PIN, NOT + * red evidence that the fix works. The `zh` and `de` cases are the genuinely + * red ones. Every expectation spells its tag explicitly; none is computed from + * a bare `toLocale*` call against the runner (the objectui#4513 trap). + * + * Predicted before running, and matched: **2 red, 1 green-both-sides**. + */ + +import React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { I18nProvider, LocalizationProvider } from '@object-ui/i18n'; +import { registerAllFields } from '@object-ui/fields'; +import { ActionProvider } from '@object-ui/react'; +import { ObjectGrid } from '../ObjectGrid'; + +registerAllFields(); + +/** + * Local-parts ISO strings (no `Z`, no offset): `new Date` reads these as LOCAL + * time, so the rendered day is 15 / 4 in every timezone rather than drifting + * across a boundary the way a bare `'2024-03-15'` (UTC midnight) would. + * 2024 is a non-current year on purpose — `formatDate`'s default branch DROPS + * the year when it matches the current one, so this keeps the expectation + * stable in whatever calendar year the suite runs. + */ +const ROWS = [ + { + id: '1', + name: 'Alice', + // Two of the branch's three key spellings: `…date…` and `…_at…`. + close_date: '2024-03-15T12:00:00', + signed_at: '2024-07-04T09:30:00', + }, +]; + +/** A session: the UI language the user picked, plus the tenant's regional default. */ +function renderSession(language: string, tenantLocale?: string) { + return render( + + + + + + + , + ); +} + +/** Open the record-detail overlay the way a user does: click a row. */ +async function openDetail(): Promise { + fireEvent.click(await screen.findByText('Alice')); + await waitFor(() => expect(screen.getByTestId('record-detail-panel')).toBeInTheDocument()); + return screen.getByTestId('record-detail-panel').textContent ?? ''; +} + +afterEach(() => cleanup()); + +describe('ObjectGrid record detail — the date fallback follows the display locale (objectui#4541)', () => { + it('zh session renders the Chinese date form at the fallback site', async () => { + renderSession('zh'); + const panel = await openDetail(); + + expect(panel).toContain('2024年3月15日'); + expect(panel).toContain('2024年7月4日'); + // The defect itself: the machine's English form must not survive. + expect(panel).not.toContain('Mar 15, 2024'); + expect(panel).not.toContain('Jul 4, 2024'); + }); + + /** + * PIN — green on both sides. The runner's machine locale is `en-US` and it + * agrees with `en` byte for byte on this branch, so this case asserts the + * English output is unchanged by the fix. It is NOT evidence the fix works. + */ + it('en session output is byte-identical (must-not-change)', async () => { + renderSession('en'); + const panel = await openDetail(); + + expect(panel).toContain('Mar 15, 2024'); + expect(panel).toContain('Jul 4, 2024'); + }); + + /** + * `useDisplayLocale()` is `tenantLocale || uiLanguage || 'en'` — the TENANT's + * configured regional default outranks the active UI language. `de` is + * chosen because `15. März 2024` matches neither the machine's + * `Mar 15, 2024` nor `zh`'s `2024年3月15日`, so this case is genuinely red + * before the fix instead of passing by coincidence. + */ + it('an explicit tenant locale outranks the active UI language', async () => { + renderSession('zh', 'de'); + const panel = await openDetail(); + + expect(panel).toContain('15. März 2024'); + expect(panel).toContain('4. Juli 2024'); + expect(panel).not.toContain('2024年3月15日'); + expect(panel).not.toContain('Mar 15, 2024'); + }); +});