From 4c3826b091ff36ce88d942686c01a2f72d524852 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 08:49:33 +0000 Subject: [PATCH] fix(plugin-gantt): tooltip numbers and currency follow the display locale (#4553) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tooltip value formatter in ObjectGantt's `tasks` memo had its four temporal call sites threaded with `useDisplayLocale()` by objectui#4272. The numeric cases beside them passed no locale, so they reached `new Intl.NumberFormat(undefined, ...)` — the machine's locale, which is neither of the repo's two locale channels. One tooltip rendered two conventions: a German session read `5. Jan. 2024` on the date row and `1,234.50` on the amount row below it, where German groups with `.` and marks the decimal with `,`. Inverted separators read as a different number, not an unstyled one. `number`/`integer`/`float`/`decimal` and `currency` now pass the `displayLocale` already read at component level, via each formatter's existing locale parameter. No formatter signature and no memo dependency changed; the package's .d.ts files are byte-identical. `percent` is deliberately NOT threaded: `formatPercent(value, precision)` takes no locale parameter at all, so closing that half needs a `@object-ui/fields` signature change, which is outside this card's ruled surface. It is pinned by a test and escalated on #4553. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 --- .../gantt-tooltip-number-locale-4553.md | 35 +++ .../src/ObjectGantt.numberLocale.test.tsx | 294 ++++++++++++++++++ packages/plugin-gantt/src/ObjectGantt.tsx | 33 +- 3 files changed, 360 insertions(+), 2 deletions(-) create mode 100644 .changeset/gantt-tooltip-number-locale-4553.md create mode 100644 packages/plugin-gantt/src/ObjectGantt.numberLocale.test.tsx diff --git a/.changeset/gantt-tooltip-number-locale-4553.md b/.changeset/gantt-tooltip-number-locale-4553.md new file mode 100644 index 000000000..e3d294277 --- /dev/null +++ b/.changeset/gantt-tooltip-number-locale-4553.md @@ -0,0 +1,35 @@ +--- +'@object-ui/plugin-gantt': patch +--- + +Gantt tooltip numbers and currency follow the display locale (objectui#4553). + +`formatFieldValue`, the tooltip value formatter inside ObjectGantt's `tasks` +memo, had its four TEMPORAL call sites threaded with `useDisplayLocale()` by +objectui#4272. The numeric cases beside them passed no locale, so they reached +`new Intl.NumberFormat(undefined, …)` — the MACHINE's locale, which is neither +of the repo's two locale channels. + +One tooltip therefore rendered two conventions. A German session read +`5. Jan. 2024` on the date row and `1,234.50` on the amount row directly below +it, where German groups with `.` and marks the decimal with `,`. Inverted +separators do not read as an unstyled number; they read as a different number. +The currency row was affected in the symbol's POSITION too — `1.234,50 EUR` +rather than `EUR1,234.50` — while the currency CODE itself was already resolved +correctly (objectui#4542 made the memo watch it); only the locale rendering that +code was missing. + +`number` / `integer` / `float` / `decimal` and `currency` now pass the +`displayLocale` already read at component level, using each formatter's existing +locale parameter. No formatter signature changed and no memo dependency changed +(`displayLocale` has been in that array since objectui#4272), so this is +consumer-side threading only: the package's `.d.ts` files are byte-identical and +English output is unchanged at every touched site. + +Known gap, tracked on objectui#4553: the `percent` row still does not follow the +display locale. `formatPercent(value, precision)` takes no locale parameter — +it is `${percentDisplayValue(value).toFixed(precision)}%`, so it builds no +`Intl.NumberFormat` at all and renders in NO locale rather than the machine's +(ASCII decimal mark, never grouped, identical on every machine). Closing that +needs a `@object-ui/fields` signature change, which is outside this change's +ruled surface, and is pinned by a test here so the gap cannot drift unnoticed. diff --git a/packages/plugin-gantt/src/ObjectGantt.numberLocale.test.tsx b/packages/plugin-gantt/src/ObjectGantt.numberLocale.test.tsx new file mode 100644 index 000000000..e90bf20b3 --- /dev/null +++ b/packages/plugin-gantt/src/ObjectGantt.numberLocale.test.tsx @@ -0,0 +1,294 @@ +/** + * 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#4553 — the gantt tooltip's NUMERIC rows rendered outside the + * display-locale channel, next to date rows that follow it. + * + * `formatFieldValue` (the tooltip value formatter inside ObjectGantt's `tasks` + * memo) had its four TEMPORAL call sites threaded with `useDisplayLocale()` by + * objectui#4272 / PR #4544. The three numeric ones beside them passed no + * locale: + * + * case 'number': ... return formatNumber(Number(value)); + * case 'currency': return formatCurrency(Number(value), resolveFieldCurrency(def, tenantCurrency)); + * case 'percent': return formatPercent(Number(value)); + * + * One tooltip, two conventions: a `de` session read `5. Jan. 2024` on the date + * row and `1,234.50` on the amount row, where the German convention is + * `1.234,50`. The separators are INVERTED, so the amount is not merely + * unstyled — it reads as a different number. + * + * ── The card's premise held for two of the three formatters, not three ─── + * Measured against `@object-ui/fields` before writing any fix (the signature + * measurement objectui#4553's ruling required): + * + * - `formatNumber(value, decimals = 2, locale?)` → locale is parameter 3 ✔ + * - `formatCurrency(value, currency?, locale?)` → locale is parameter 3 ✔ + * - `formatPercent(value, precision = 0)` → NO locale parameter ✘ + * + * `formatPercent` is `${percentDisplayValue(value).toFixed(precision)}%`. It + * never constructs an `Intl.NumberFormat` and never reaches + * `formatDisplayNumber`, so — unlike the other two — it does not render in the + * MACHINE's locale. It renders in NO locale: `toFixed` always emits an ASCII + * `.` and never a grouping separator, on every machine. Threading a locale + * into it is therefore not a call-site change at all; it needs a new parameter + * on a `@object-ui/fields` export, which this card's surface excludes. The + * percent case below pins the CURRENT behavior as evidence of that inversion — + * it is not an endorsement of it, and it is expected to go red on the day + * `formatPercent` grows a locale. + * + * ── Harness (objectui#4542 / PR #4554's lessons) ───────────────────────── + * `getGanttConfig` returns a FRESH object literal on the flattened top-level + * path but `schema.gantt` BY REFERENCE on the metadata path, so these cases use + * a module-constant `schema.gantt` and a module-constant `dataSource` — the + * identity a metadata-sourced schema actually has. The session config carries a + * LOCALE ONLY (the currency under test is declared on the field), so nothing + * re-runs the memo through the currency channel and the locale is what these + * cases measure. + * + * Provider-mounting file (objectui#4514's structural split) — the pure-function + * halves of this family live in `packages/fields/src/__tests__`. + * + * ── Directions, predicted in writing BEFORE the run, then measured ─────── + * Runner: node v22.22.2 / ICU 78.2 / machine locale en-US. + * + * de number `1,234.50` → `1.234,50` RED before, green after + * de currency `EUR1,234.50` → `1.234,50 EUR` RED before, green after + * (symbol moves to the END as well as the + * separators inverting — doubly un-fakeable) + * de percent `1235%` → `1235%` GREEN BOTH SIDES — the inverted + * premise, pinned as evidence + * en number / currency / percent GREEN BOTH SIDES — PINS. `en` and + * the runner's `en-US` coincide, so + * these assert byte-identical + * English output, NOT that the fix + * works. + * + * Every expectation spells its tag ('en' / 'de') explicitly; none is built from + * the runner (objectui#4513). + */ + +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.dateLocale.test.tsx / +// ObjectGantt.currencyDep.test.tsx: 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} +
+ ))} +
+ ), +})); + +/** + * `1234.5` is the card's own repro value and it discriminates in all three + * rows at once: four significant digits force a GROUPING separator and the + * fraction forces a DECIMAL separator, and `de` inverts both. A whole number + * would exercise neither `formatCurrency`'s fractional width nor the decimal + * mark, and a value below 1000 would not show grouping at all. + * + * `due_date` rides along as objectui#4272 / PR #4544's date row: it is what + * makes "one tooltip, two conventions" assertable in a single tooltip rather + * than argued in prose. + */ +const ROWS = [ + { + id: '1', + name: 'Task 1', + start_date: '2024-01-01', + end_date: '2024-01-10', + qty: 1234.5, + amount: 1234.5, + ratio: 1234.5, + due_date: '2024-01-05', + }, +]; + +/** + * `amount` declares its OWN currency code, so `resolveFieldCurrency` never + * consults the tenant default and the session config can stay locale-only — + * the isolation PR #4554 measured (a session that sets both channels re-runs + * this memo through the currency dep, which would mask what is being tested). + */ +const OBJECT_SCHEMA = { + fields: { + name: { type: 'text' }, + start_date: { type: 'date' }, + end_date: { type: 'date' }, + qty: { type: 'number', label: 'Qty' }, + amount: { type: 'currency', label: 'Amount', currency: 'EUR' }, + ratio: { type: 'percent', label: 'Ratio' }, + due_date: { type: 'date', label: 'Due' }, + }, +}; + +// Module-constant: stable identity across re-renders — see the harness note. +const DATA_SOURCE: DataSource = { + find: vi.fn().mockResolvedValue({ data: ROWS }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue(OBJECT_SCHEMA), +} as any; + +// `schema.gantt` is handed back BY REFERENCE by `getGanttConfig` on this path. +const SCHEMA: any = { + type: 'gantt', + gantt: { + titleField: 'name', + startDateField: 'start_date', + endDateField: 'end_date', + tooltipFields: ['qty', 'amount', 'ratio', 'due_date'], + }, + data: { provider: 'object', object: 'tasks' }, +}; + +/** Tooltip row handles, in `tooltipFields` order. */ +const QTY = 'gv-field-1-0'; +const AMOUNT = 'gv-field-1-1'; +const RATIO = 'gv-field-1-2'; +const DUE = 'gv-field-1-3'; + +/** + * A session: the UI language the user picked plus the tenant's regional + * default. `useDisplayLocale()` resolves tenant regional default → active UI + * language → 'en'. + */ +function renderSession(language: string, tenantLocale?: string) { + return render( + + + + + , + ); +} + +/** + * German puts a NO-BREAK SPACE (U+00A0) between the amount and the currency + * sign, and the sign itself is U+20AC. Written as escapes rather than pasted + * so the expectation is readable and greppable in both spellings. + */ +const NBSP = '\u00a0'; +const EURO = '\u20ac'; + +afterEach(() => cleanup()); + +describe('ObjectGantt tooltips — numeric values follow the display locale (objectui#4553)', () => { + /** + * THE RED CASE. Pre-fix both rows render through `Intl` with an `undefined` + * tag — the MACHINE's locale, which on this runner is en-US — so a German + * session read `1,234.50`: the grouping and decimal separators inverted + * against the convention, i.e. a different number, not merely a different + * style. + */ + it('de session renders German grouping and decimal separators', async () => { + renderSession('de'); + await waitFor(() => expect(screen.getByTestId('gv-fields-1')).toBeDefined()); + + expect(screen.getByTestId(QTY).textContent).toBe('Qty=1.234,50'); + expect(screen.getByTestId(AMOUNT).textContent).toBe(`Amount=1.234,50${NBSP}${EURO}`); + }); + + /** + * The card's actual complaint, asserted as ONE tooltip: the date row and the + * amount row now speak the same convention. Pre-fix the date row was already + * German (PR #4544) while the amount row beside it was not. + */ + it('the date row and the numeric rows agree on one convention', async () => { + renderSession('de'); + await waitFor(() => expect(screen.getByTestId('gv-fields-1')).toBeDefined()); + + expect(screen.getByTestId(DUE).textContent).toBe('Due=5. Jan. 2024'); + expect(screen.getByTestId(QTY).textContent).toBe('Qty=1.234,50'); + }); + + /** + * `useDisplayLocale()` ranks the TENANT's configured regional default above + * the active UI language, exactly as PR #4544 pinned for the date rows. Red + * before the fix for the same reason as the case above. + */ + it('an explicit tenant locale outranks the active UI language', async () => { + renderSession('en', 'de'); + await waitFor(() => expect(screen.getByTestId('gv-fields-1')).toBeDefined()); + + // CURRENCY asserted first here, deliberately: a failing `expect` aborts its + // test, so with the number row asserted first in every case the currency + // site would never report its own red and would ride on the number site's + // evidence. This ordering makes the two call sites independently red. + expect(screen.getByTestId(AMOUNT).textContent).toBe(`Amount=1.234,50${NBSP}${EURO}`); + expect(screen.getByTestId(QTY).textContent).toBe('Qty=1.234,50'); + }); + + /** + * PIN, green on both sides: the runner's machine locale is en-US and `en` + * agrees with it here, so this asserts the English output is BYTE-IDENTICAL + * across the change. It is not evidence that the fix works. + */ + 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(QTY).textContent).toBe('Qty=1,234.50'); + expect(screen.getByTestId(AMOUNT).textContent).toBe(`Amount=${EURO}1,234.50`); + expect(screen.getByTestId(RATIO).textContent).toBe('Ratio=1235%'); + expect(screen.getByTestId(DUE).textContent).toBe('Due=Jan 5, 2024'); + }); + + /** + * PIN, green on both sides — and the card's PREMISE INVERSION, recorded in + * the tree rather than only in a report. + * + * objectui#4553 states that all three numeric formatters reach + * `formatDisplayNumber` with `locale: undefined`. `formatPercent` does not: + * it is `${percentDisplayValue(value).toFixed(precision)}%`, so it touches no + * `Intl` at all and takes no locale parameter to thread. Its output is + * therefore not the machine's locale but NO locale — ASCII, ungrouped, + * identical on every machine. German would want `1.235 %`; every session + * gets `1235%`. + * + * Fixing it means adding a parameter to a `@object-ui/fields` export, which + * is outside this card's ruled surface — so it is pinned as-is here and + * escalated. This assertion is a change-detector for a known-wrong output, + * NOT a statement that the output is right; it is expected to fail on the + * day `formatPercent` grows a locale, and that failure is the signal to + * update it. + */ + it('de percent stays ASCII and ungrouped — formatPercent takes no locale (objectui#4553 inverted premise)', async () => { + renderSession('de'); + await waitFor(() => expect(screen.getByTestId('gv-fields-1')).toBeDefined()); + + expect(screen.getByTestId(RATIO).textContent).toBe('Ratio=1235%'); + // The German rendering this row cannot currently produce, spelled out so + // the gap is legible without re-deriving it from the formatter. + expect(screen.getByTestId(RATIO).textContent).not.toBe(`Ratio=1.235${NBSP}%`); + }); +}); diff --git a/packages/plugin-gantt/src/ObjectGantt.tsx b/packages/plugin-gantt/src/ObjectGantt.tsx index d7e11fa11..6a683bb0c 100644 --- a/packages/plugin-gantt/src/ObjectGantt.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.tsx @@ -656,13 +656,42 @@ export const ObjectGantt: React.FC = ({ return formatDate(value as any, undefined, { locale: displayLocale }); case 'datetime': return formatDateTime(value as any, { locale: displayLocale }); + // The numeric rows take the same `displayLocale` as the temporal ones + // above (objectui#4553). Without it these reached + // `new Intl.NumberFormat(undefined, …)`, i.e. the MACHINE's locale — + // neither of the repo's two locale channels — so one tooltip rendered + // two conventions: a German session read `5. Jan. 2024` on the date row + // and `1,234.50` on the amount row beside it, where German groups with + // `.` and marks the decimal with `,`. Inverted separators do not read + // as an unstyled number; they read as a DIFFERENT number. case 'number': case 'integer': case 'float': case 'decimal': - return formatNumber(Number(value)); + // `decimals` keeps its default: the display width is not this card's + // subject, only the locale that renders it. + return formatNumber(Number(value), undefined, displayLocale); case 'currency': - return formatCurrency(Number(value), resolveFieldCurrency(def as any, tenantCurrency)); + // The CODE was already resolved correctly (objectui#4542 made the memo + // watch it); the locale that renders that code is what was missing. + return formatCurrency( + Number(value), + resolveFieldCurrency(def as any, tenantCurrency), + displayLocale, + ); + // ⚠️ `percent` is deliberately NOT threaded here, and it is the one row + // in this switch that still ignores the display locale. + // `formatPercent(value, precision)` takes no locale parameter at all: + // it is `${percentDisplayValue(value).toFixed(precision)}%`, so it + // constructs no `Intl.NumberFormat` and never reaches + // `formatDisplayNumber`. Its output is therefore not the machine's + // locale but NO locale — ASCII `.`, never grouped, identical on every + // machine (`1235%` where German wants `1.235 %`). Fixing it means + // growing a `@object-ui/fields` export's signature, which objectui#4553's + // ruled surface excludes; escalated on that card rather than patched + // with a locale-aware reimplementation here, which would fork percent + // formatting away from the list cell and the dashboard measure + // formatter that share `percentDisplayValue` today. case 'percent': return formatPercent(Number(value)); case 'boolean':