diff --git a/.changeset/date-formatter-residue-locale-4272.md b/.changeset/date-formatter-residue-locale-4272.md new file mode 100644 index 000000000..9c6bc922a --- /dev/null +++ b/.changeset/date-formatter-residue-locale-4272.md @@ -0,0 +1,39 @@ +--- +'@object-ui/fields': minor +'@object-ui/plugin-grid': patch +'@object-ui/plugin-gantt': patch +--- + +The date formatter's last three en-US channels now follow the display locale +(objectui#4272). + +objectui#4468 (PR #4512) pointed every date *renderer* at `useDisplayLocale()`. +Three channels were out of its reach because they are properties of the +formatter's signature and of its callers rather than of any renderer, so a `zh` +console still met English dates in three places: + +- **`formatDate`'s `'short'` branch** hardcoded + `toLocaleDateString('en-US', { month: 'short' })`, so it rendered an English + month even when the caller had threaded `options.locale` into that very call. + Its only consumers are ObjectGrid's two mobile-card date cells, which threaded + no locale — fixing either half alone moves nothing, so both land here. +- **`formatDateTime` took no options parameter at all**, so no caller could + localize it however hard it tried; it always handed `Intl` an `undefined` tag, + which means the MACHINE's locale — neither of the repo's two locale channels. + The parameter is optional and lands together with its consumers, plugin-gantt's + four tooltip call sites. +- **The lookup picker's MongoDB `$date` fallback** called a bare + `toLocaleDateString()` with no tag. + +One resolver everywhere, as before: `useDisplayLocale()` (tenant regional +default → active UI language → `'en'`). `Intl` accepts `'zh'` verbatim, so there +is still no mapping table anywhere. + +English output is byte-identical at every touched site — `en` and `en-US` agree +on all twelve short month names — and the `'short'` layout itself is unchanged: +only the month token is localized, the compact `"Jan 15, '24"` shape around it +is a deliberate fixed layout for narrow cards. + +`@object-ui/fields` is `minor` because `formatDateTime`'s new optional parameter +is visible in the package's entry `.d.ts`; the plugin packages' own `.d.ts` files +are byte-identical, so their change is module-local. diff --git a/packages/fields/src/__tests__/date-formatter-residue-4272.test.ts b/packages/fields/src/__tests__/date-formatter-residue-4272.test.ts new file mode 100644 index 000000000..b8ed8eace --- /dev/null +++ b/packages/fields/src/__tests__/date-formatter-residue-4272.test.ts @@ -0,0 +1,120 @@ +/** + * 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#4272 — the two FORMATTER-level en-US channels the #4468 fix + * (PR #4512) deliberately left behind. + * + * PR #4512 pointed every date *renderer* at `useDisplayLocale()`. It could not + * reach these two, because both are properties of the formatter's signature + * rather than of any renderer: + * + * 1. `formatDate`'s `'short'` branch hardcoded the tag — + * `date.toLocaleDateString('en-US', { month: 'short' })` — so it rendered + * an English month name even when a caller DID thread `options.locale` + * into the very same call. The sibling default branch two lines below + * already honored `options?.locale`; `'short'` was the outlier. + * 2. `formatDateTime` took no options parameter at all, so no caller could + * localize it however hard it tried; it passed `undefined` to `Intl`, + * which means "the MACHINE's locale" — neither of the repo's two locale + * channels. + * + * ── Why these cases live in a file with NO provider ────────────────────── + * objectui#4514: `useObjectTranslation()` outside a provider reports + * react-i18next's GLOBAL language, which any `I18nProvider` mounted earlier in + * the same file leaves behind. These are pure-function cases that touch no + * hook at all, so they are immune — but they are kept apart from the + * provider-mounting cases regardless, so the split stays structural rather + * than something a later edit can quietly erode. The provider-mounted halves + * of this card live in `RecordPickerDialog.dateLocale.test.tsx`, + * `plugin-grid/.../mobileCardDateLocale.test.tsx` and + * `plugin-gantt/.../ObjectGantt.dateLocale.test.tsx`. + * + * ── Directions (measured, not presumed) ────────────────────────────────── + * Runner: node v22.22.2 / ICU 78.2 / TZ=UTC / machine locale en-US. + * `en` and `en-US` produce an IDENTICAL `{month:'short'}` for all 12 months, + * so every `en` case below is GREEN ON BOTH SIDES — it is the byte-identical + * must-not-change pin, NOT red evidence. The `zh` and `de` cases are the ones + * that actually go red against unfixed code. + */ + +import { describe, it, expect } from 'vitest'; +import { formatDate, formatDateTime } from '../index'; + +/** Local-parts dates: the rendered day is then the same in every timezone. */ +const AUG = new Date(2026, 7, 15, 12, 0, 0); +const OCT = new Date(2026, 9, 15, 12, 0, 0); +/** A non-current year, so the year-dropping default branch is not in play. */ +const INSTANT = new Date(2024, 0, 5, 8, 30, 0); + +describe("formatDate 'short' honors the threaded locale (objectui#4272)", () => { + it('zh renders the Chinese month, not the hardcoded English one', () => { + expect(formatDate(AUG, 'short', { locale: 'zh' })).toBe("8月 15, '26"); + }); + + /** + * A non-CJK second locale, on a month whose English and German short forms + * differ (`Oct` / `Okt`). August would NOT discriminate — German also + * abbreviates it `Aug` — so this case is deliberately October. + */ + it('de renders the German month abbreviation', () => { + expect(formatDate(OCT, 'short', { locale: 'de' })).toBe("Okt 15, '26"); + }); + + /** + * PIN, green on both sides: `en` and the runner's `en-US` agree on every + * month name, so this asserts the English output is byte-identical after + * the change. It is not evidence that the fix works. + */ + it('en output is byte-identical (must-not-change)', () => { + expect(formatDate(AUG, 'short', { locale: 'en' })).toBe("Aug 15, '26"); + }); + + it('the composite shape around the month is unchanged', () => { + // Day and 2-digit year, apostrophe and comma placement: the `'short'` + // contract is "Jan 15, '24" and only the month token was localized. + expect(formatDate(AUG, 'short', { locale: 'en' })).toMatch(/^Aug 15, '26$/); + }); +}); + +describe('formatDateTime accepts a locale at all (objectui#4272)', () => { + it('zh renders the Chinese datetime form', () => { + expect(formatDateTime(INSTANT, { locale: 'zh' })).toBe('2024年1月5日 08:30'); + }); + + /** PIN, green on both sides — see the `en` note above. */ + it('en output is byte-identical (must-not-change)', () => { + expect(formatDateTime(INSTANT, { locale: 'en' })).toBe('Jan 5, 2024, 08:30 AM'); + }); + + /** + * Backward compatibility: the parameter is optional and an existing caller + * that passes nothing keeps the exact behavior it had. + * + * This is the ONE case where building the expectation from the runner is + * the correct move rather than the objectui#4513 trap: the contract under + * test IS "still the runtime default", so both sides of the comparison are + * meant to be the machine's locale. Every other case in this file spells + * its tag explicitly. + */ + it('no options — still the runtime default, unchanged', () => { + const runtimeDefault = INSTANT.toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + expect(formatDateTime(INSTANT)).toBe(runtimeDefault); + }); + + it('the empty / invalid guards are untouched', () => { + expect(formatDateTime('', { locale: 'zh' })).toBe('—'); + expect(formatDateTime('not-a-date', { locale: 'zh' })).toBe('—'); + }); +}); diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index aff8ca522..f754751e9 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -558,8 +558,13 @@ export function formatDate(value: string | Date | number, style?: string, option if (!(date instanceof Date) || isNaN(date.getTime())) return '—'; if (style === 'short') { - // Compact format for mobile: "Jan 15, '24" - const month = date.toLocaleDateString('en-US', { month: 'short' }); + // Compact format for mobile: "Jan 15, '24" / "1月 15, '24". + // Only the MONTH token is localized: the surrounding compact shape (day, + // apostrophe + 2-digit year) is a deliberate fixed layout for narrow + // cards, not a locale-derived one. The tag comes from `options.locale` + // like the default branch below — hardcoding `'en-US'` here made this the + // one branch that ignored a locale its caller had threaded (objectui#4272). + const month = date.toLocaleDateString(options?.locale, { month: 'short' }); const day = date.getDate(); const year = String(date.getFullYear()).slice(-2); return `${month} ${day}, '${year}`; @@ -583,14 +588,22 @@ export function formatDate(value: string | Date | number, style?: string, option } /** - * Format datetime value - */ -export function formatDateTime(value: string | Date | number): string { + * Format datetime value. + * + * `options` mirrors {@link formatDate}'s and is optional, so an existing + * caller that passes nothing keeps the exact runtime-default behavior it had. + * Before objectui#4272 the parameter did not exist at all, which meant no + * caller could localize this function however hard it tried — it always handed + * `Intl` an `undefined` tag, i.e. the MACHINE's locale, which is neither of + * the repo's two locale channels. Callers should pass the tag from + * `useDisplayLocale()`. + */ +export function formatDateTime(value: string | Date | number, options?: DateDisplayOptions): string { if (value === null || value === undefined || value === '') return '—'; const date = value instanceof Date ? value : new Date(value as any); if (!(date instanceof Date) || isNaN(date.getTime())) return '—'; - - return date.toLocaleDateString(undefined, { + + return date.toLocaleDateString(options?.locale, { year: 'numeric', month: 'short', day: 'numeric', diff --git a/packages/fields/src/widgets/RecordPickerDialog.dateLocale.test.tsx b/packages/fields/src/widgets/RecordPickerDialog.dateLocale.test.tsx new file mode 100644 index 000000000..ecb03ec57 --- /dev/null +++ b/packages/fields/src/widgets/RecordPickerDialog.dateLocale.test.tsx @@ -0,0 +1,124 @@ +/** + * 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#4272 — the lookup picker's MongoDB `$date` fallback rendered in the + * MACHINE's locale. + * + * `renderCellContent`'s plain-text fallback runs whenever a column has no + * usable field descriptor / cell renderer — the shape a string-authored + * `lookup_columns` entry against an object with no `fieldsMeta` produces. For + * an expanded Mongo value it did: + * + * 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`. + * + * ── 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. + * + * This file mounts providers; the pure-function cases for the same card are + * kept in `__tests__/date-formatter-residue-4272.test.ts` (objectui#4514). + */ + +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, waitFor, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { I18nProvider, LocalizationProvider } from '@object-ui/i18n'; +import { RecordPickerDialog } from './RecordPickerDialog'; + +/** The exact expanded-Mongo shape the picker receives from the server. */ +const records = [ + { id: 'r1', name: 'Northwind', signed_on: { $date: new Date(2026, 7, 11, 0, 0, 0).toISOString() } }, +]; + +function makeDataSource() { + return { find: vi.fn(async () => ({ data: records, total: records.length })) } as any; +} + +/** + * A session: the UI language the user picked plus the tenant's regional + * default (usually absent). Mirrors `date-locale-channel.test.tsx`'s harness + * so both halves of this card describe a session the same way. + */ +function renderSession(language: string, tenantLocale?: string) { + return render( + + + {}} + onSelect={() => {}} + dataSource={makeDataSource()} + objectName="accounts" + // Deliberately no `type` and no `fieldsMeta`/`cellRenderer`: this is + // what drives BOTH columns through the plain-text fallback where the + // `$date` branch lives. `name` is carried purely as a render anchor — + // the picker table shows only the columns it is given, so waiting on + // a field that is not a column would wait forever. + columns={[ + { field: 'name', label: 'Name' }, + { field: 'signed_on', label: 'Signed On' }, + ]} + /> + + , + ); +} + +/** + * The dialog renders through a Radix portal, so its table is NOT inside the + * `container` `render()` returns — it is mounted at the document body. Reading + * `container.textContent` here would assert against an empty string, which + * passes every `not.toContain` for the wrong reason. + */ +function bodyText(): string { + return document.body.textContent ?? ''; +} + +afterEach(() => cleanup()); + +describe('RecordPickerDialog — the $date fallback follows the display locale (objectui#4272)', () => { + 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'); + }); + + /** 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 () => { + renderSession('en'); + await waitFor(() => expect(screen.getByText('Northwind')).toBeInTheDocument()); + expect(bodyText()).toContain('8/11/2026'); + }); + + /** + * `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. + */ + 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'); + }); +}); diff --git a/packages/fields/src/widgets/RecordPickerDialog.tsx b/packages/fields/src/widgets/RecordPickerDialog.tsx index 10bc50030..abc588b72 100644 --- a/packages/fields/src/widgets/RecordPickerDialog.tsx +++ b/packages/fields/src/widgets/RecordPickerDialog.tsx @@ -42,7 +42,7 @@ import type { DataSource, LookupColumnDef, LookupFilterDef } from '@object-ui/ty // — shared with plugin-list's `buildEffectiveFilter` and plugin-view's // ObjectView, so a spec `ViewFilterRule[]` lowers in exactly one place. import { mergeFilterNodes } from '@object-ui/core'; -import { useSafeFieldLabel } from '@object-ui/i18n'; +import { useSafeFieldLabel, useDisplayLocale } from '@object-ui/i18n'; import { useFieldTranslation } from './useFieldTranslation'; import { useRecordQuery } from './useRecordQuery'; @@ -496,6 +496,9 @@ export function RecordPickerDialog({ }: RecordPickerDialogProps) { const { t } = useFieldTranslation(); const { translateOptions } = useSafeFieldLabel(); + // The one date/number locale resolver: tenant regional default → active UI + // language → 'en' (objectui#4272). Read unconditionally at component level. + const displayLocale = useDisplayLocale(); // Query state (records/loading/error/total + page/search/sort) lives in the // shared useRecordQuery kernel — instantiated after mergedFilter below. @@ -860,13 +863,13 @@ export function RecordPickerDialog({ // 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(); + if (val.$date) return new Date(val.$date).toLocaleDateString(displayLocale); if (val.name || val.label) return String(val.name || val.label); return JSON.stringify(val); } if (typeof val === 'boolean') return val ? 'Yes' : 'No'; return String(val); - }, [cellRenderer, titleFormat, displayField, columnFieldDescriptors]); + }, [cellRenderer, titleFormat, displayField, columnFieldDescriptors, displayLocale]); // Render sort indicator for a column const renderSortIcon = useCallback((field: string) => { diff --git a/packages/plugin-gantt/src/ObjectGantt.dateLocale.test.tsx b/packages/plugin-gantt/src/ObjectGantt.dateLocale.test.tsx new file mode 100644 index 000000000..b15c7fbf8 --- /dev/null +++ b/packages/plugin-gantt/src/ObjectGantt.dateLocale.test.tsx @@ -0,0 +1,132 @@ +/** + * 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#4272 — the gantt tooltip's date and datetime rows rendered in the + * MACHINE's locale. + * + * `formatFieldValue` formats tooltip values through the shared + * `@object-ui/fields` formatters, and threaded no locale into any of its four + * temporal call sites (two `formatDate`, two `formatDateTime` — the schema + * `date`/`datetime` cases plus the no-schema ISO-string sniffing pair). + * + * `formatDateTime` could not have been localized here even deliberately: it + * took no options parameter at all until this card. That is why the parameter + * and these consumers land together — a parameter with nothing passing one + * would have grown the public surface for no consumer. + * + * ── Directions ─────────────────────────────────────────────────────────── + * Runner machine locale is `en-US`, so the `en` cases are GREEN ON BOTH SIDES + * — the byte-identical must-not-change pins. The `zh` cases are the red ones. + * + * Dates here are in 2024 on purpose: `formatDate`'s default branch drops the + * year when it matches the current one, so a non-current year keeps the + * expectation stable no matter which calendar year the suite runs in. + */ + +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, waitFor, cleanup } from '@testing-library/react'; +import { I18nProvider, LocalizationProvider } from '@object-ui/i18n'; +import { ObjectGantt } from './ObjectGantt'; +import type { DataSource } from '@object-ui/types'; + +// Same GanttView stub idiom as ObjectGantt.test.tsx: the tooltip rows are +// surfaced as `gv-field--` handles so their formatted text is +// assertable without rendering the real timeline. +vi.mock('./GanttView', () => ({ + GanttView: ({ tasks }: any) => ( +
+ {tasks.map((t: any) => ( +
+ {t.title} + {t.fields ? ( +
+ {t.fields.map((f: any, i: number) => ( + {f.label}={f.value} + ))} +
+ ) : null} +
+ ))} +
+ ), +})); + +const ttData = [ + { + id: '1', + name: 'Task 1', + start_date: '2024-01-01', + end_date: '2024-01-10', + // A `date` field and a `datetime` field — the two schema-typed branches. + due_date: '2024-01-05', + reviewed_at: '2024-01-05T08:30:00', + }, +]; + +function makeDataSource(): DataSource { + return { + find: vi.fn().mockResolvedValue({ data: ttData }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue({ + fields: { + name: { type: 'text' }, + start_date: { type: 'date' }, + end_date: { type: 'date' }, + due_date: { type: 'date', label: 'Due' }, + reviewed_at: { type: 'datetime', label: 'Reviewed' }, + }, + }), + } as any; +} + +function renderSession(language: string) { + const schema: any = { + type: 'gantt', + gantt: { + titleField: 'name', + startDateField: 'start_date', + endDateField: 'end_date', + tooltipFields: ['due_date', 'reviewed_at'], + }, + data: { provider: 'object', object: 'tasks' }, + }; + return render( + + + + + , + ); +} + +afterEach(() => cleanup()); + +describe('ObjectGantt tooltips — temporal values follow the display locale (objectui#4272)', () => { + it('zh session renders Chinese date and datetime forms', async () => { + renderSession('zh'); + await waitFor(() => expect(screen.getByTestId('gv-fields-1')).toBeDefined()); + expect(screen.getByTestId('gv-field-1-0').textContent).toBe('Due=2024年1月5日'); + expect(screen.getByTestId('gv-field-1-1').textContent).toBe('Reviewed=2024年1月5日 08:30'); + }); + + /** PIN — green on both sides; the runner's machine locale is `en-US`. */ + it('en session output is byte-identical (must-not-change)', async () => { + renderSession('en'); + await waitFor(() => expect(screen.getByTestId('gv-fields-1')).toBeDefined()); + expect(screen.getByTestId('gv-field-1-0').textContent).toBe('Due=Jan 5, 2024'); + expect(screen.getByTestId('gv-field-1-1').textContent).toBe('Reviewed=Jan 5, 2024, 08:30 AM'); + }); +}); diff --git a/packages/plugin-gantt/src/ObjectGantt.tsx b/packages/plugin-gantt/src/ObjectGantt.tsx index 0e24618fc..6b3743a41 100644 --- a/packages/plugin-gantt/src/ObjectGantt.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.tsx @@ -27,7 +27,7 @@ import { toast } from 'sonner'; import type { ObjectGridSchema, DataSource, ViewData, GanttConfig } from '@object-ui/types'; import { GanttConfigSchema } from '@objectstack/spec/ui'; import { useNavigationOverlay, SchemaRendererContext } from '@object-ui/react'; -import { useLocalization, resolveFieldCurrency } from '@object-ui/i18n'; +import { useLocalization, useDisplayLocale, resolveFieldCurrency } from '@object-ui/i18n'; import { RecordDetailDrawer, deriveRecordPageHref } from '@object-ui/plugin-detail'; import { AlertDialog, @@ -430,6 +430,9 @@ export const ObjectGantt: React.FC = ({ const [objectSchema, setObjectSchema] = useState(null); // Tenant default currency (ADR-0053) for currency tooltips lacking a code. const { currency: tenantCurrency } = useLocalization(); + // The one date/number locale resolver: tenant regional default → active UI + // language → 'en' (objectui#4272). Read unconditionally at component level. + const displayLocale = useDisplayLocale(); const { t } = useGanttTranslation(); // Surface write-back failures (拖拽/连线/删除/行内编辑) as an error toast — @@ -643,16 +646,16 @@ export const ObjectGantt: React.FC = ({ // No field def at all → sniff ISO date / datetime strings so raw // `2026-08-14T08:00:00.000Z` payloads still format like real date fields. if (type == null && typeof value === 'string') { - if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return formatDate(value); + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return formatDate(value, undefined, { locale: displayLocale }); if (/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(value) && !isNaN(new Date(value).getTime())) { - return formatDateTime(value); + return formatDateTime(value, { locale: displayLocale }); } } switch (type) { case 'date': - return formatDate(value as any); + return formatDate(value as any, undefined, { locale: displayLocale }); case 'datetime': - return formatDateTime(value as any); + return formatDateTime(value as any, { locale: displayLocale }); case 'number': case 'integer': case 'float': @@ -765,7 +768,10 @@ export const ObjectGantt: React.FC = ({ data: record, }; }).filter(task => !isNaN(task.start.getTime()) && !isNaN(task.end.getTime())); - }, [data, ganttConfig, objectSchema]); + // `displayLocale` is a dependency because the tooltip strings are FORMATTED + // in here: without it a language switch would leave already-built tooltips + // on the previous locale. + }, [data, ganttConfig, objectSchema, displayLocale]); // Dynamic Group by accessor (动态 Group by). Resolves each task's grouping // value off its backing record, mapping select options / lookups to their diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 3f7938018..fcdd1d8f2 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -28,7 +28,7 @@ import type { I18nLabel } from '@objectstack/spec/ui'; import { SchemaRenderer, useDataScope, useNavigationOverlay, useAction, useSafeFieldLabel, usePredicateScope, useRelatedRecordActions } from '@object-ui/react'; import { createSafeTranslation } from '@object-ui/i18n'; import { getCellRenderer, resolveCellRendererType, formatCurrency, formatCompactCurrency, formatDate, formatPercent, humanizeLabel, getBadgeColorClasses, FieldEditWidget, hasFieldEditWidget, DISCRETE_EDIT_TYPES, coerceToSafeValue } from '@object-ui/fields'; -import { useLocalization, resolveFieldCurrency } from '@object-ui/i18n'; +import { useLocalization, useDisplayLocale, resolveFieldCurrency } from '@object-ui/i18n'; import { stateMachineNextValues, isFieldInlineEditable } from './inline-edit-options'; import { Badge, Button, NavigationOverlay, EmptyValue, @@ -535,6 +535,9 @@ export const ObjectGrid: React.FC = ({ const [error, setError] = useState(null); // Tenant default currency (ADR-0053) backstops amount cells that lack a code. const { currency: tenantCurrency } = useLocalization(); + // The one date/number locale resolver: tenant regional default → active UI + // language → 'en' (objectui#4272). Read unconditionally at component level. + const displayLocale = useDisplayLocale(); const { t } = useGridTranslation(); const { fieldLabel: resolveFieldLabel, translateOptions, actionLabel: resolveActionLabel } = useSafeFieldLabel(); const [objectSchema, setObjectSchema] = useState(null); @@ -2994,7 +2997,7 @@ export const ObjectGrid: React.FC = ({ {dateCols[0] && ( {row[dateCols[0].accessorKey] - ? formatDate(row[dateCols[0].accessorKey], 'short') + ? formatDate(row[dateCols[0].accessorKey], 'short', { locale: displayLocale }) : '—'} )} @@ -3011,7 +3014,7 @@ export const ObjectGrid: React.FC = ({
{col.header} - {row[col.accessorKey] ? formatDate(row[col.accessorKey], 'short') : '—'} + {row[col.accessorKey] ? formatDate(row[col.accessorKey], 'short', { locale: displayLocale }) : '—'}
))} diff --git a/packages/plugin-grid/src/__tests__/mobileCardDateLocale.test.tsx b/packages/plugin-grid/src/__tests__/mobileCardDateLocale.test.tsx new file mode 100644 index 000000000..78d8e1808 --- /dev/null +++ b/packages/plugin-grid/src/__tests__/mobileCardDateLocale.test.tsx @@ -0,0 +1,138 @@ +/** + * 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#4272 — the mobile card view's compact date cells rendered an + * English month on a non-English console. + * + * Below the 768px breakpoint ObjectGrid switches to a stacked card layout + * whose date cells call `formatDate(value, 'short')`. Two things had to change + * together for this to move at all, which is why they land in one PR: + * + * - `@object-ui/fields`' `'short'` branch hardcoded + * `toLocaleDateString('en-US', { month: 'short' })`, so it ignored a + * locale even when one was threaded; and + * - these two call sites threaded no locale in the first place. + * + * Fixing either half alone leaves the rendered output exactly as it was. + * + * ── Directions ─────────────────────────────────────────────────────────── + * The runner's machine locale is `en-US` and `en`/`en-US` share every short + * month name, so the `en` case here is GREEN ON BOTH SIDES — the + * byte-identical must-not-change pin. The `zh` case is the red one. + */ + +import React from 'react'; +import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest'; +import { render, screen, waitFor, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { I18nProvider, LocalizationProvider } from '@object-ui/i18n'; +import { ActionProvider, SchemaRendererProvider } from '@object-ui/react'; +import { registerAllFields } from '@object-ui/fields'; +import { ObjectGrid } from '../ObjectGrid'; + +registerAllFields(); + +beforeAll(() => { + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = vi.fn() as any; + } +}); + +const ORIGINAL_INNER_WIDTH = window.innerWidth; + +function setMobileWidth() { + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 390 }); +} + +afterEach(() => { + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: ORIGINAL_INNER_WIDTH }); + cleanup(); +}); + +/** + * Mid-month and a non-current year: the day can never drift across a month + * boundary, and the assertion is on the month token alone, so the case is + * stable in any timezone and in any calendar year the suite runs in. + */ +const ROWS = [ + { id: 'a1', account_name: 'Northwind', close_date: '2024-03-15', start_date: '2024-06-20' }, +]; + +function makeDataSource() { + return { + find: vi.fn(async () => ({ data: ROWS, total: ROWS.length, hasMore: false, pageSize: 50 })), + getObjectSchema: async (name: string) => ({ + name, + fields: { + id: { type: 'text' }, + account_name: { type: 'text', label: 'Account Name' }, + close_date: { type: 'date', label: 'Close Date' }, + start_date: { type: 'date', label: 'Start Date' }, + }, + }), + } as any; +} + +function renderSession(language: string) { + const ds = makeDataSource(); + const schema: any = { + type: 'object-grid', + objectName: 'showcase_account', + // The card view partitions the columns AFTER the first (the card title) + // and routes them by NAME: `classify()` matches `dateKeys` substrings, so + // a column must be named like a date to reach the compact `'short'` cells + // at all — `closed_on` would be classified 'other' and rendered by the + // ordinary DateCellRenderer instead. Two date columns are carried so both + // `'short'` sites run: `dateCols[0]` (the unlabelled density row) and + // `dateCols.slice(1)` (the labelled rows below it). + columns: [ + { field: 'account_name', label: 'Account Name' }, + { field: 'close_date', label: 'Close Date', type: 'date' }, + { field: 'start_date', label: 'Start Date', type: 'date' }, + ], + pagination: { pageSize: 50 }, + }; + return render( + + + + + + + + + , + ); +} + +describe("ObjectGrid mobile cards — the 'short' date cells follow the display locale (objectui#4272)", () => { + it('zh session renders the Chinese month token at both short sites', async () => { + setMobileWidth(); + const { container } = renderSession('zh'); + await waitFor(() => expect(screen.getByText('Northwind')).toBeInTheDocument()); + // dateCols[0] — the unlabelled density row. + expect(container.textContent).toContain('3月'); + // dateCols.slice(1) — the labelled rows below it. + expect(container.textContent).toContain('6月'); + expect(container.textContent).not.toContain('Mar'); + expect(container.textContent).not.toContain('Jun'); + }); + + /** PIN — green on both sides; `en` and the runner's `en-US` agree here. */ + it('en session output is byte-identical (must-not-change)', async () => { + setMobileWidth(); + const { container } = renderSession('en'); + await waitFor(() => expect(screen.getByText('Northwind')).toBeInTheDocument()); + expect(container.textContent).toContain("Mar 15, '24"); + expect(container.textContent).toContain("Jun 20, '24"); + }); +});