Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
03445df
test(test-support): move the runtime locale tripwire into @object-ui/…
claude Sep 24, 2026
d37c652
fix(fields): GridField number faces format in the declared display lo…
claude Sep 24, 2026
5d039a2
fix(plugin-charts): chart ticks, single-value face and tooltip format…
claude Sep 24, 2026
636b922
fix(components): export-progress counts and debug event times use the…
claude Sep 24, 2026
e861eeb
fix(core): date_min / date_max default messages print their bound in …
claude Sep 24, 2026
ef07e1c
fix(plugin-form): submission count, money stack and conflict time use…
claude Sep 24, 2026
1d2f51d
fix(plugin-chatbot): inbox date tail and local-mode message times use…
claude Sep 24, 2026
22922f1
fix(plugin-report): the exported report's "Generated:" time uses the …
claude Sep 24, 2026
b265f4f
fix(plugin-dashboard): record-count badge and renderFieldValue curren…
claude Sep 24, 2026
567bc37
fix(plugin-grid): ObjectGrid's inferred currency faces use the displa…
claude Sep 24, 2026
774419c
fix(plugin-designer): VersionHistory times use the display locale
claude Sep 24, 2026
eba506a
fix(app-shell): fourteen console / metadata-admin faces use the displ…
claude Sep 24, 2026
b33d88d
fix(console): approvals inbox, audit log and flow runs faces use the …
claude Sep 24, 2026
19f73b7
test(i18n): one repo-wide machine-locale census over every @object-ui…
claude Sep 24, 2026
23fd641
chore(changeset): declare objectui#9909's display-locale sweep
claude Sep 24, 2026
49db879
test: type-check fixes in three display-locale pins
claude Sep 24, 2026
612d71a
docs(changeset): the objectui#9786 census pin became the repo-wide ce…
claude Sep 24, 2026
9f7db7d
test(plugin-chatbot): pin the auto-response message stamp too
claude Sep 24, 2026
77acc60
fix(app-shell): keep MarketplacePackagePage's pinned useObjectTransla…
claude Sep 24, 2026
ec86935
test: mount three console.warn-counting suites under an I18nProvider
claude Sep 24, 2026
689004a
test: type the display-locale pins instead of casting to any
claude Sep 24, 2026
5139618
docs(changeset): make the objectui#9909 changeset say what the pins a…
claude Sep 24, 2026
cfb74c2
Merge remote-tracking branch 'origin/main' into claude/issue-9909-rep…
claude Sep 24, 2026
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
14 changes: 8 additions & 6 deletions .changeset/9786-detail-timestamps-display-locale.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,14 @@ the argument each call actually receives, which is the only way to catch a
variable that resolves to `undefined`, and the only way to reach
`RecordMetaFooter` — whose absolute face is a `TooltipContent` child that Radix
keeps out of the DOM until the tooltip opens.
`machineLocaleCensus-9786.test.ts` reddens when a new bare, `undefined`- or
hard-coded-tag call site is written anywhere in this package's source; it masks
comments before it counts, and it declares `InlineFieldInput`'s `en-CA` as the
one exemption with its reason — that call feeds `<input type="date">`, whose
value HTML defines as `YYYY-MM-DD`, so it is an ISO formatter and not a display
locale.
A census pin reddens when a new bare, `undefined`- or hard-coded-tag call site
is written; it masks comments before it counts, and it declares
`InlineFieldInput`'s `en-CA` as an exemption with its reason — that call feeds
`<input type="date">`, whose value HTML defines as `YYYY-MM-DD`, so it is an ISO
formatter and not a display locale. It landed scoped to this package and, under
the ruling on this card, became the one repository-wide census in
`@object-ui/i18n` (`machineLocaleCensus-9909.test.ts`, objectui#9909), which
carries that exemption forward.

⛔ Not repaired here: the same class outside this package. A mechanical scan of
the whole repository's non-test sources — comments masked, the same matcher the
Expand Down
105 changes: 105 additions & 0 deletions .changeset/9909-machine-locale-sweep.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
---
'@object-ui/types': minor
'@object-ui/plugin-report': minor
'@object-ui/core': patch
'@object-ui/i18n': patch
'@object-ui/components': patch
'@object-ui/fields': patch
'@object-ui/app-shell': patch
'@object-ui/console': patch
'@object-ui/plugin-charts': patch
'@object-ui/plugin-chatbot': patch
'@object-ui/plugin-dashboard': patch
'@object-ui/plugin-designer': patch
'@object-ui/plugin-detail': patch
'@object-ui/plugin-form': patch
'@object-ui/plugin-grid': patch
---

Dates and numbers across the console and the plugins format in the session's
display locale instead of the machine's (objectui#9909).

Every one of these faces handed `Intl` — directly, or through a formatter called
without one — either no locale tag or an explicit `undefined`, which is not "the
user's locale": it is the locale of the machine the browser runs on, which is
neither of this repository's two locale channels.
A German or Spanish session therefore read, beside a translated label, a date or
an amount grouped and decimal-marked the machine's way — and an amount with
inverted separators does not read as unformatted, it reads as a different
number. They now go through `useDisplayLocale()` (tenant regional default, then
the active UI language, then `'en'`), and a plain helper takes that tag from its
caller. The surfaces:

- **`@object-ui/fields`** — `GridField`'s numeric and currency cells (list mode
and computed columns) and both total cells. The same cell helper's date branch
already used the display locale, so a single grid row read two conventions at
once.
- **`@object-ui/plugin-charts`** — ISO-date x-axis ticks, compact y-axis ticks,
the single-value face, spec `format` strings and the tooltip value.
- **`@object-ui/app-shell`** — the organization invitations, members and
accept-invitation dates; marketplace version dates; the AI conversation row's
older-than-a-week date and its tooltip; the build-debug token count; the
record approvals timeline; and the metadata-admin audit, history, external
datasource snapshot, schema-browser row estimate, flow-run start and job
next-fire faces. The audit, history and flow-run panels format these in the
DISPLAY locale, not in the `locale` prop they take for their UI strings
(`AuditPanel` defaults that prop to `'en-US'`; `HistoryPanel` and
`FlowRunsPanel` leave it optional).
- **`@object-ui/console`** — the approvals inbox's timestamp tooltips, amounts
and payload summary; the audit log's timestamp column; the flow runs table and
its run detail.
- **`@object-ui/components`** — the export dialog's record counts and the debug
panel's event times.
- **`@object-ui/plugin-form`** — the analytics submission count, the
master-detail subtotal / tax / total stack and the edit-conflict dialog's
"their save" time.
- **`@object-ui/plugin-chatbot`** — the approvals inbox's past-30-days date and
the times stamped on local-mode chat messages (the user's and the
auto-response).
- **`@object-ui/plugin-dashboard`** — the record-count badge, and the currency
and date-format branches of `renderFieldValue`, which was handed the display
locale and spent it only on its percent branch.
- **`@object-ui/plugin-grid`** — the record-detail panel's inferred currency
value and the mobile card's amount line, siblings of date cells already on the
display locale.
- **`@object-ui/plugin-designer`** — `VersionHistory`'s version times.

A host that mounts one of the components that newly read the display locale with
NO `I18nProvider` now gets react-i18next's once-per-module `NO_I18NEXT_INSTANCE`
notice in the console on the first such mount; the faces still render, in the
channel's `'en'` last resort, and mounting an `I18nProvider` (as the console
does) avoids the notice.

**Additive API** (nothing that compiled before stops compiling):

- `@object-ui/types`: `ValidationContext` gains an optional `locale`. The
`@object-ui/core` validation engine prints the `date_min` / `date_max` bound in
it; omitted, `Intl` follows the runtime default, as `formatDisplayNumber`
already declares for a caller with no locale in hand.
- `@object-ui/plugin-report`: `exportReport`, `exportAsHTML` and `exportAsPDF`
take a trailing optional `locale` for the exported file's "Generated:" time,
and `LiveExportOptions` gains an optional `locale` that `exportWithLiveData`
forwards. Omitted, the time uses the display channel's own last resort
(`'en'`), never the machine's locale. `ReportViewer` passes the session's
display locale. The locale is deliberately NOT a `ReportExportConfig` member:
that type is authored report metadata, and a display locale belongs to the
session.

**One census, repository-wide.** `plugin-detail`'s machine-locale census pin
(objectui#9786) is now a single test, `machineLocaleCensus-9909.test.ts` in
`@object-ui/i18n`, covering every workspace package whose manifest depends on
`@object-ui/i18n`. It refuses any call site that passes nothing, `undefined`,
the `'default'` pseudo-tag (a subtag no locale data answers, so `Intl` resolves
it to the machine's locale) or a hard-coded tag, unless the site is declared
with its reason — for example the `catch` fallback for a tag `Intl` itself
rejected, or an ISO formatter feeding `<input type="date">`. Each declared site
is counted exactly, so deleting one without its entry is refused too. The
per-package pin it replaces is deleted. The runtime tripwire that observes the
argument each locale-taking call (`Intl` constructors, `Date` and `Number`
`toLocale*`) actually receives moved into the private `@object-ui/test-support`
package, and every surface above is pinned with it: the same data under two
declared locales must render differently, NO locale-taking call may receive the
machine's locale (nothing, `undefined` or `'default'`), and the surface's own
calls must receive the declared tag. A call carrying another declared tag, such
as the `'en'` of the session's UI language, is tolerated by design: only the
machine's locale is the defect.
240 changes: 240 additions & 0 deletions apps/console/src/pages/consoleFaces.displayLocale-9909.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
/**
* 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.
*/

/**
* The console's system and developer pages read the DECLARED session locale,
* never the machine's (objectui#9909): the approvals inbox (queue amount,
* drawer amount, payload summary — number, epoch-ms date and ISO date-time —
* and every timestamp tooltip), the audit log's timestamp column, and the flow
* runs table.
*
* Every helper here has a `catch`, and none of them is the deliberate
* fallback shape: each catches an unparseable VALUE, not a tag `Intl`
* rejected, so the bare call was the primary leg.
*
* Each surface is read twice — under a declared `de-DE` tenant locale and a
* declared `en` one — and must read differently; the runtime tripwire then
* checks the argument every locale-taking call received. `useObjectTranslation`
* is stubbed (as the pages' sibling suites stub it) so the strings are fixed;
* `useDisplayLocale` reads the REAL `LocalizationProvider` value either way.
*/

import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, within } from '@testing-library/react';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions';
import { LocalizationProvider } from '@object-ui/i18n';
import { isMachineLocale, recordLocaleArgumentsAsync } from '@object-ui/test-support';

/** 2020-03-04 15:30:00 UTC — the suite runs in UTC. */
const { STORED, approvalsApiStub, ADAPTER, AUTH, I18N } = vi.hoisted(() => {
const STORED = '2020-03-04T15:30:00.000Z';
const EPOCH_MS = Date.UTC(2020, 2, 4, 15, 30, 0);
const ROW: Record<string, unknown> = {
id: 'req_1',
process_name: 'purchase_approval',
process_label: 'Purchase Approval',
object_name: 'showcase_purchase',
object_label: 'Purchase',
record_id: 'po_1',
record_title: 'PO-4417',
status: 'pending',
pending_approvers: ['u_1'],
submitter_id: 'u_2',
submitter_name: 'Sam Submitter',
submitted_at: STORED,
payload: {
amount: 1234.5,
quantity: 98765,
closed_at: EPOCH_MS,
review_note: STORED,
},
};
const approvalsApiStub = {
listRequests: async () => ({ data: [ROW], total: 1 }),
getRequest: async () => ({ data: ROW }),
listActions: async () => ({
data: [{ id: 'a1', request_id: 'req_1', action: 'submit', actor_id: 'u_2', actor_name: 'Sam Submitter', created_at: STORED }],
}),
approve: async () => ({ data: ROW, finalized: true }),
reject: async () => ({ data: ROW, finalized: true }),
};
const listRuns = async () => ({ runs: [{ id: 'run_1', status: 'completed', startedAt: STORED, durationMs: 9 }] });
const ADAPTER = {
find: async () => ({ data: [{ id: 'po_1' }] }),
getObjectSchema: async () => ({ fields: {} }),
getClient: () => ({
meta: { getItems: async (type: string) => (type === 'flow' ? [{ spec: { name: 'sync_flow', label: 'Sync' } }] : []) },
automation: { execute: async () => ({ success: true }), listRuns, getRun: async () => ({ run: null }) },
}),
};
const AUTH = { user: { id: 'u_1', email: 'approver@example.com' } };
const I18N = {
t: (key: string, options?: Record<string, unknown>) => String(options?.defaultValue ?? key),
language: 'en',
};
return { STORED, approvalsApiStub, ADAPTER, AUTH, I18N };
});

vi.mock('@object-ui/i18n', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useObjectTranslation: () => I18N,
}));

vi.mock('@object-ui/auth', async (importOriginal) => {
const authFetch = async () => new Response('{}', { status: 200 });
return {
...(await importOriginal<Record<string, unknown>>()),
useAuth: () => AUTH,
createAuthenticatedFetch: () => authFetch,
TokenStorage: { get: () => null },
};
});

vi.mock('@object-ui/app-shell', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAdapter: () => ADAPTER,
useMetadata: () => ({ objects: [] }),
DeclaredActionsBar: () => null,
isViaOverrideRow: () => false,
}));

vi.mock('../services/approvalsApi', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
approvalsApi: approvalsApiStub,
}));

import { ApprovalsInboxPage } from './system/ApprovalsInboxPage';
import { AuditLogPage } from './system/AuditLogPage';
import { FlowRunsPage } from './developer/FlowRunsPage';

afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});

function permissions(): MePermissionsResponse {
return {
authenticated: true,
userId: 'u_1',
tenantId: 't_1',
roles: [],
permissionSets: [],
objects: {},
fields: {},
systemPermissions: ['setup.access'],
};
}

/** Everything a reader can see or hover: text plus every `title` tooltip. */
function faceOf(root: HTMLElement): string {
const titles = [...root.querySelectorAll('[title]')].map((el) => el.getAttribute('title') ?? '');
return `${root.textContent ?? ''} | ${titles.join(' | ')}`.replace(/\s+/g, ' ');
}

interface Surface {
name: string;
read: (locale: string) => Promise<string>;
de: RegExp[];
en: RegExp[];
}

const SURFACES: Surface[] = [
{
name: 'ApprovalsInboxPage — queue and drawer',
read: async (locale) => {
render(
<LocalizationProvider value={{ locale }}>
<MePermissionsProvider initialPermissions={permissions()}>
<MemoryRouter initialEntries={['/apps/app/system/approvals?request=req_1']}>
<Routes>
<Route path="/apps/:appName/system/approvals" element={<ApprovalsInboxPage />} />
</Routes>
</MemoryRouter>
</MePermissionsProvider>
</LocalizationProvider>,
);
const dialog = await screen.findByRole('dialog');
await within(dialog).findByText('Purchase Approval');
await within(dialog).findAllByText(/Sam Submitter/);
const text = faceOf(document.body);
cleanup();
return text;
},
// amount · quantity · epoch-ms date · ISO date-time
de: [/1\.234,5/, /98\.765/, /4\.3\.2020(?!,)/, /4\.3\.2020, 15:30:00/],
en: [/1,234\.5/, /98,765/, /3\/4\/2020(?!,)/, /3\/4\/2020, 3:30:00\sPM/],
},
{
name: 'AuditLogPage — timestamp column',
read: async (locale) => {
vi.stubGlobal(
'fetch',
vi.fn(async () =>
new Response(JSON.stringify({ records: [{ id: 'log_1', created_at: STORED, action: 'update', object_name: 'account' }] }), {
status: 200,
}),
),
);
render(
<LocalizationProvider value={{ locale }}>
<AuditLogPage />
</LocalizationProvider>,
);
await screen.findByText('account');
const text = faceOf(document.body);
cleanup();
vi.unstubAllGlobals();
return text;
},
de: [/4\.3\.2020, 15:30:00/],
en: [/3\/4\/2020, 3:30:00\sPM/],
},
{
name: 'FlowRunsPage — runs table start time',
read: async (locale) => {
render(
<LocalizationProvider value={{ locale }}>
<FlowRunsPage />
</LocalizationProvider>,
);
await screen.findAllByText(/2020/);
const text = faceOf(document.body);
cleanup();
return text;
},
de: [/4\.3\.2020, 15:30:00/],
en: [/3\/4\/2020, 3:30:00\sPM/],
},
];

describe('console page faces follow the declared session locale (objectui#9909)', () => {
it.each(SURFACES)('$name — says the de-DE faces under a de-DE session', async ({ read, de }) => {
const text = await read('de-DE');
for (const face of de) expect(text, `got: ${text.slice(0, 1500)}`).toMatch(face);
});

it.each(SURFACES)('$name — keeps its en faces under an en session', async ({ read, en }) => {
const text = await read('en');
for (const face of en) expect(text, `got: ${text.slice(0, 1500)}`).toMatch(face);
});

/** ⭐ THE PIN: the runner's own locale cannot satisfy it. */
it.each(SURFACES)('$name — is a reading of the session, not of the machine', async ({ read }) => {
expect(await read('de-DE')).not.toBe(await read('en'));
});

it.each(SURFACES)('$name — every locale-taking call receives the declared tag', async ({ read }) => {
const calls = await recordLocaleArgumentsAsync(async () => {
await read('de-DE');
});
expect(calls.some((c) => c.locale === 'de-DE'), `saw: ${JSON.stringify(calls).slice(0, 2000)}`).toBe(true);
expect(calls.filter(isMachineLocale), 'these call sites formatted in the machine locale').toEqual([]);
});
});
Loading
Loading