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
39 changes: 39 additions & 0 deletions .changeset/date-formatter-residue-locale-4272.md
Original file line number Diff line number Diff line change
@@ -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.
120 changes: 120 additions & 0 deletions packages/fields/src/__tests__/date-formatter-residue-4272.test.ts
Original file line number Diff line number Diff line change
@@ -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('—');
});
});
27 changes: 20 additions & 7 deletions packages/fields/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand All @@ -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',
Expand Down
124 changes: 124 additions & 0 deletions packages/fields/src/widgets/RecordPickerDialog.dateLocale.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<I18nProvider
config={{ defaultLanguage: language, detectBrowserLanguage: false }}
persistLanguage={false}
>
<LocalizationProvider value={{ locale: tenantLocale }}>
<RecordPickerDialog
open
onOpenChange={() => {}}
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' },
]}
/>
</LocalizationProvider>
</I18nProvider>,
);
}

/**
* 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');
});
});
9 changes: 6 additions & 3 deletions packages/fields/src/widgets/RecordPickerDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) => {
Expand Down
Loading
Loading