From 42608dc68d28e91039b201d74755908d3775f20d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 14:44:41 +0000 Subject: [PATCH] fix(core,plugin-dashboard,plugin-charts): run resolved select-option labels through the i18n bundle on analytics surfaces (#4030) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The analytics label net resolved a select dimension's option label and then displayed the object's authored English label, so a chart legend read `Orion Engineered Carbons` while the related list on the same page read 欧励隆. Applies the ONE existing channel — `fieldOptionLabel` (`{ns}.fieldOptions...`, what list/form/kanban/record picker surfaces already translate options through) — at the net's output, on the shared option list every consumer reads: chart axis/legend, dotted table/pivot cells, that table's CSV, per-category colours and category order. - core: `localizeFieldOptions` (pure mirror of `translateOptions`), an optional translator on `buildDimensionLabelMap` (which also keys the authored English label, so a server-resolved row re-translates), and `resolveDimensionFieldMeta` — the same single relationship walk, keeping the object that OWNS the terminal field because that is what the bundle key names. - plugin-dashboard / plugin-charts: keep the fetched metadata locale-free and derive colours + label maps during render, so a language switch re-labels in place instead of waiting for a refetch. Identity is untouched: a segment clicked as 欧励隆 still drills by `orion`, and an untranslated option (or an `en` console) renders exactly as today. Refs objectstack#5076 --- .../analytics-option-label-i18n-4030.md | 17 + .../utils/__tests__/chart-series.i18n.test.ts | 257 +++++++++++++ packages/core/src/utils/chart-series.ts | 169 ++++++++- packages/plugin-charts/src/ObjectChart.tsx | 85 +++-- .../plugin-dashboard/src/DatasetWidget.tsx | 123 ++++-- .../DatasetWidget.optionLabelI18n.test.tsx | 351 ++++++++++++++++++ 6 files changed, 923 insertions(+), 79 deletions(-) create mode 100644 .changeset/analytics-option-label-i18n-4030.md create mode 100644 packages/core/src/utils/__tests__/chart-series.i18n.test.ts create mode 100644 packages/plugin-dashboard/src/__tests__/DatasetWidget.optionLabelI18n.test.tsx diff --git a/.changeset/analytics-option-label-i18n-4030.md b/.changeset/analytics-option-label-i18n-4030.md new file mode 100644 index 000000000..65206812a --- /dev/null +++ b/.changeset/analytics-option-label-i18n-4030.md @@ -0,0 +1,17 @@ +--- +'@object-ui/core': patch +'@object-ui/plugin-dashboard': patch +'@object-ui/plugin-charts': patch +--- + +Analytics surfaces now run resolved select-option labels through the locale bundle — the chart legend and the related list on one page stop disagreeing + +A dashboard widget grouped by a `select` field rendered the option's authored English label while the related list beside it rendered the translation. The decisive evidence in objectui#4030 is the stored value `orion`: the chart read `Orion Engineered Carbons`, a string with no resemblance to the value and matching the object's `label` byte for byte. So the analytics path had already RESOLVED the option label — it simply never ran the result through the i18n bundle before display. (`domestic → Domestic` differs from its value by case alone, which is why the first diagnosis, "the report groups by stored value", was wrong.) + +There is exactly one resolution channel and this change reuses it rather than adding a chart-side dialect: `fieldOptionLabel` from `useObjectLabel`, i.e. `{ns}.fieldOptions...` — the convention `@objectstack/spec` names objectui as the reader of, and the one list, form, kanban and record-picker surfaces already translate select options through. The bundle is applied ONCE, at the output of the label net that landed in objectui#4053/#4263, on the shared option list every consumer reads: chart axis and legend, the table/pivot cells of a dotted dimension, that table's CSV export, per-category colours and the declared category order. `@object-ui/core` gains `localizeFieldOptions` (the pure mirror of `translateOptions`), an optional translator on `buildDimensionLabelMap`, and `resolveDimensionFieldMeta` — the same single relationship walk `resolveDimensionFieldOptions` performs, now keeping the object that OWNS the terminal field, because for `crm_account.industry` the bundle key is `crm_account`, not the dataset's base object. + +Two properties the fix is shaped around. The rows reach this net keyed either way — by stored value when the server did not resolve the dimension, by the English label when it did (ADR-0021) — and the reported screen is the second case, so the map answers to both keys and lands on the same translated display. And identity is untouched: `relabelDimensions` still rewrites display only, so a drilled chart segment clicked as `欧励隆` filters by `orion`, bucket ids and pivot totals keep their raw keys, and an option with no bundle entry (or an `en` console) renders exactly the authored label it renders today. + +The per-locale work moved from the metadata fetch into the render, so switching language now re-labels in place instead of waiting for a refetch. + +Not covered, and unchanged here: a LOCAL select dimension on a table/pivot, whose label the server resolves and whose client-side net is deliberately off (objectui#4263), and a dashboard global filter's own field label, which has no object name in its metadata to key a bundle lookup with — tracked on objectui#4030. diff --git a/packages/core/src/utils/__tests__/chart-series.i18n.test.ts b/packages/core/src/utils/__tests__/chart-series.i18n.test.ts new file mode 100644 index 000000000..82e4d483e --- /dev/null +++ b/packages/core/src/utils/__tests__/chart-series.i18n.test.ts @@ -0,0 +1,257 @@ +/** + * 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#4030 (source thread objectstack#5076) — the analytics label net + * RESOLVES a select dimension's option label and then never runs it through the + * locale bundle, so a chart legend reads `Orion Engineered Carbons` (the + * object's authored English `label`, verbatim) while the related list on the + * same page reads `欧励隆`. + * + * The decisive fixture is the reporter's `orion` row, and it is decisive + * because the rendered string bears NO resemblance to the stored value: it can + * only have come from the option label, which is what separates this from "the + * report groups by stored value". `domestic → Domestic` differs from its value + * by case alone and is exactly why the first diagnosis was wrong; both are + * fixtures here for that reason. + * + * These are the pure pins for the seam itself. The surface pins (what a chart + * legend / table cell actually renders under a zh-CN bundle) live in + * `plugin-dashboard`. + * + * DIRECTIONS, written before the reverse verification was run: + * - the NO-TRANSLATOR cases are green on both sides — they are the pre-#4030 + * behaviour restated, and the whole safety argument of this change is that + * an app with no bundle entry keeps the authored label it has today; + * - every case that passes a translator is RED before the change: without the + * seam the extra argument is ignored and the authored English label comes + * back; + * - `resolveDimensionFieldMeta` / `localizeFieldOptions` do not exist before + * it at all, so those cases fail at import. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { + buildDimensionLabelMap, + buildOptionColorMap, + buildCategoryOrder, + localizeFieldOptions, + relabelDimensions, + resolveDimensionFieldMeta, + resolveDimensionFieldOptions, + type OptionLabelTranslator, +} from '../chart-series'; + +/** The card's own field: English option labels, one of them unrecognisable. */ +const COMPETITOR_OPTIONS = [ + { value: 'cabot', label: 'Cabot', color: 'blue' }, + { value: 'orion', label: 'Orion Engineered Carbons', color: 'green' }, +]; + +/** The zh-CN bundle the reporter authored, keyed by the STORED value. */ +const ZH: Record = { cabot: '卡博特', orion: '欧励隆' }; + +/** + * Stand-in for `useObjectLabel().fieldOptionLabel(object, field, …)` bound to + * one field: a bundle hit wins, a miss falls back to the authored label. That + * fallback is not a detail — it IS today's behaviour, and every "no bundle + * entry" pin below rides on it. + */ +const zhTranslator: OptionLabelTranslator = (value, authored) => ZH[value] ?? authored; + +describe('buildDimensionLabelMap — the locale bundle at the label net (objectui#4030)', () => { + it('keeps the pre-#4030 map when no translator is supplied', () => { + // Control, green on both sides of the change: without a bundle in play the + // map is byte-for-byte what it always was. + expect(buildDimensionLabelMap(COMPETITOR_OPTIONS)).toEqual({ + cabot: 'Cabot', + orion: 'Orion Engineered Carbons', + }); + }); + + it('maps the stored value to the TRANSLATED label', () => { + expect(buildDimensionLabelMap(COMPETITOR_OPTIONS, zhTranslator)).toMatchObject({ + cabot: '卡博特', + orion: '欧励隆', + }); + }); + + it('ALSO maps the authored English label, so a server-resolved row re-translates', () => { + // The rows reach this net keyed either way: value-keyed when the server did + // not resolve the dimension (the reason the net exists), already + // label-keyed when it did (ADR-0021). The reported symptom is the second + // case — a legend showing the object's `label` verbatim — so the map must + // answer to both keys or the fix misses the very screen in the issue. + const map = buildDimensionLabelMap(COMPETITOR_OPTIONS, zhTranslator); + expect(map?.['Orion Engineered Carbons']).toBe('欧励隆'); + expect(map?.Cabot).toBe('卡博特'); + }); + + it('falls back to the authored label for an option the bundle does not carry', () => { + // The card's whole downstream-impact section is about NOT losing the + // English label for locales that have no translation. + const map = buildDimensionLabelMap( + [...COMPETITOR_OPTIONS, { value: 'birla', label: 'Birla Carbon' }], + zhTranslator, + ); + expect(map?.birla).toBe('Birla Carbon'); + }); + + it('emits no second key when the translation IS the authored label (en locale)', () => { + // Under `en` the resolver returns the fallback, so the map must collapse + // back to exactly the untranslated one — no `Cabot → Cabot` self-entries. + const identity: OptionLabelTranslator = (_v, authored) => authored; + expect(buildDimensionLabelMap(COMPETITOR_OPTIONS, identity)).toEqual( + buildDimensionLabelMap(COMPETITOR_OPTIONS), + ); + }); + + it('translates a BARE-STRING option, which has a value but no distinct label', () => { + // `options: ['orion']` is a legal select spelling and the bundle is keyed + // by the stored value, so there is nothing to resolve but something to + // translate. Without a translator it still yields nothing (pinned in + // `chart-series.test.ts`), because value and label are then the same string. + expect(buildDimensionLabelMap(['cabot', 'orion'])).toBeNull(); + expect(buildDimensionLabelMap(['cabot', 'orion'], zhTranslator)).toEqual({ + cabot: '卡博特', + orion: '欧励隆', + }); + }); + + it('lets a VALUE key win over another option’s authored-label key', () => { + // Pathological but decidable: one option's stored value is spelled exactly + // like another's English label. The value is the identity, so it wins. + const map = buildDimensionLabelMap( + [ + { value: 'Cabot', label: 'Cabot Corporation' }, + { value: 'cabot', label: 'Cabot' }, + ], + (value, authored) => (value === 'Cabot' ? '卡博特集团' : value === 'cabot' ? '卡博特' : authored), + ); + expect(map?.Cabot).toBe('卡博特集团'); + }); +}); + +describe('relabelDimensions × the translated map (the rendered end of it)', () => { + const rows = [ + { competitor_name: 'orion', deals: 7 }, + { competitor_name: 'cabot', deals: 3 }, + ]; + + it('puts the translation on the category with the measure still attached', () => { + const map = buildDimensionLabelMap(COMPETITOR_OPTIONS, zhTranslator); + expect(relabelDimensions(rows, { competitor_name: map! })).toEqual([ + { competitor_name: '欧励隆', deals: 7 }, + { competitor_name: '卡博特', deals: 3 }, + ]); + }); + + it('translates rows the SERVER already resolved to the English label', () => { + const serverResolved = [ + { competitor_name: 'Orion Engineered Carbons', deals: 7 }, + { competitor_name: 'Cabot', deals: 3 }, + ]; + const map = buildDimensionLabelMap(COMPETITOR_OPTIONS, zhTranslator); + expect(relabelDimensions(serverResolved, { competitor_name: map! })).toEqual([ + { competitor_name: '欧励隆', deals: 7 }, + { competitor_name: '卡博特', deals: 3 }, + ]); + }); + + it('does NOT touch the raw input rows — drill-through still filters by `orion`', () => { + // The stored value is the identity key (#4263's convention: display + // translates, identity keys do not). `relabelDimensions` returns a new + // array; the raw rows the drill filter is built from must survive. + const map = buildDimensionLabelMap(COMPETITOR_OPTIONS, zhTranslator); + relabelDimensions(rows, { competitor_name: map! }); + expect(rows[0].competitor_name).toBe('orion'); + }); +}); + +describe('localizeFieldOptions — the list/form channel, applied to analytics options', () => { + it('replaces only the label, keeping value and colour', () => { + const localized = localizeFieldOptions(COMPETITOR_OPTIONS, zhTranslator) as Array< + Record + >; + expect(localized).toEqual([ + { value: 'cabot', label: '卡博特', color: 'blue' }, + { value: 'orion', label: '欧励隆', color: 'green' }, + ]); + }); + + it('returns the input ARRAY ITSELF when nothing translated', () => { + // Identity, not just equality: an untranslated app must keep the option + // identities it had, so downstream memoization behaves exactly as before. + expect(localizeFieldOptions(COMPETITOR_OPTIONS)).toBe(COMPETITOR_OPTIONS); + expect(localizeFieldOptions(COMPETITOR_OPTIONS, (_v, authored) => authored)).toBe( + COMPETITOR_OPTIONS, + ); + }); + + it('keeps colours and declared order reachable under the TRANSLATED category', () => { + // Colours and category order are keyed by value AND label; after the + // relabel above a row's category is the translated string, so feeding both + // helpers the localized options is what keeps a "Cabot" bar blue and a + // funnel in its authored sequence in a zh-CN console. + const localized = localizeFieldOptions(COMPETITOR_OPTIONS, zhTranslator); + expect(buildOptionColorMap(localized)).toMatchObject({ orion: 'green', 欧励隆: 'green' }); + expect(buildCategoryOrder(localized)).toEqual(['cabot', '卡博特', 'orion', '欧励隆']); + }); + + it('tolerates the shapes the metadata really carries', () => { + expect(localizeFieldOptions(undefined, zhTranslator)).toBeUndefined(); + expect(localizeFieldOptions([], zhTranslator)).toEqual([]); + expect(localizeFieldOptions([null, 3, { value: 'orion', label: 'Orion Engineered Carbons' }], zhTranslator)) + .toEqual([null, 3, { value: 'orion', label: '欧励隆' }]); + }); +}); + +describe('resolveDimensionFieldMeta — WHICH object keys the bundle (objectui#4030)', () => { + const OPPORTUNITY = { + name: 'crm_opportunity', + fields: { + competitor_name: { type: 'select', options: COMPETITOR_OPTIONS }, + crm_account: { type: 'lookup', reference: 'crm_account' }, + }, + }; + const ACCOUNT = { + name: 'crm_account', + fields: { industry: { type: 'select', options: [{ value: 'edu', label: 'Education' }] } }, + }; + const load = vi.fn(async (name: string) => (name === 'crm_account' ? ACCOUNT : null)); + + it('names the BASE object for a local field', async () => { + const meta = await resolveDimensionFieldMeta(OPPORTUNITY, ['competitor_name'], load); + expect(meta.competitor_name).toEqual({ + object: 'crm_opportunity', + field: 'competitor_name', + options: COMPETITOR_OPTIONS, + }); + }); + + it('names the RELATIONSHIP TARGET for a dotted path, and the TERMINAL field', async () => { + // The bundle key is `fieldOptions...`; keying a dotted + // dimension against the dataset's base object, or against the dotted path + // as if it were a field name, resolves to nothing at all. + const meta = await resolveDimensionFieldMeta(OPPORTUNITY, ['crm_account.industry'], load); + expect(meta['crm_account.industry']).toMatchObject({ + object: 'crm_account', + field: 'industry', + }); + }); + + it('still answers `resolveDimensionFieldOptions` with the options alone', async () => { + // The #4053 / PR #4261 contract is unchanged — this is the same one walk. + await expect( + resolveDimensionFieldOptions(OPPORTUNITY, ['competitor_name', 'crm_account.industry'], load), + ).resolves.toEqual({ + competitor_name: COMPETITOR_OPTIONS, + 'crm_account.industry': ACCOUNT.fields.industry.options, + }); + }); +}); diff --git a/packages/core/src/utils/chart-series.ts b/packages/core/src/utils/chart-series.ts index 43252d2b8..e8f6f6dc7 100644 --- a/packages/core/src/utils/chart-series.ts +++ b/packages/core/src/utils/chart-series.ts @@ -261,29 +261,113 @@ export function buildCategoryRank(order: string[] | null | undefined): Map 0 ? rank : null; } +/** + * The i18n seam of the analytics label net (objectui#4030). + * + * `(storedValue, authoredLabel) => displayLabel` — the SAME signature + * `resolveGroupByLabels` (plugin-charts) already takes for the legacy aggregate + * path, which callers bind to `useSafeFieldLabel().fieldOptionLabel(object, + * field, …)`. That resolver reads `{ns}.fieldOptions...`, + * the one convention list and form surfaces already translate select options + * through (`useObjectLabel.translateOptions`, and `@objectstack/spec` names + * objectui as its reader). Analytics reuses that channel rather than growing a + * chart-side per-locale dialect. + * + * Omitted everywhere it isn't available (no i18n provider, an unresolvable + * owning object): every helper below then behaves exactly as it did before this + * seam existed. + */ +export type OptionLabelTranslator = (value: string, authoredLabel: string) => string; + +/** One option, normalized out of the `{value,label}` / bare-string spellings. */ +function optionValueLabel(opt: unknown): { value: string; label: string } | null { + if (typeof opt === 'string' || typeof opt === 'number' || typeof opt === 'boolean') { + // A bare-string option IS its own label. Nothing to resolve, but there is + // something to TRANSLATE — the bundle is keyed by the stored value. + return { value: String(opt), label: String(opt) }; + } + if (opt && typeof opt === 'object') { + const o = opt as { value?: unknown; label?: unknown }; + if (o.value != null && o.label != null) return { value: String(o.value), label: String(o.label) }; + } + return null; +} + +/** + * Run a select field's `options` through the locale bundle, returning options + * whose `label` is the translated one (objectui#4030). + * + * The mirror of `useObjectLabel().translateOptions` — the channel list and form + * surfaces localize select options through — kept pure and shape-tolerant so + * the analytics net can apply it at the point the resolved options are read. + * Everything downstream ({@link buildOptionColorMap}, + * {@link buildCategoryOrder}) keeps reading `option.label` and needs no + * knowledge of i18n, exactly like `SelectCellRenderer` on the list side. + * + * Colour, order and every other option key survive untouched — only `label` + * changes. Returns the input ARRAY ITSELF when there is no translator or + * nothing translated, so an untranslated app keeps the identities (and the + * memo/render behaviour) it had before. + */ +export function localizeFieldOptions(options: unknown, translate?: OptionLabelTranslator): unknown { + if (!translate || !Array.isArray(options) || options.length === 0) return options; + let changed = false; + const next = options.map((opt) => { + const vl = optionValueLabel(opt); + if (!vl) return opt; + const display = translate(vl.value, vl.label); + if (display === vl.label) return opt; + changed = true; + // A bare-string option becomes an object so the translation has somewhere + // to live; its value is preserved, which is the only identity that matters. + return opt && typeof opt === 'object' + ? { ...(opt as object), label: display } + : { value: vl.value, label: display }; + }); + return changed ? next : options; +} + /** * Build a `{ value → label }` map from a select/enum field's `options`, for * resolving a grouped dimension's stored value to its display label (fed to * {@link relabelDimensions}). Mirrors {@link buildOptionColorMap}. * * Options may be `{ value, label }` objects or bare strings (value == label — - * nothing to relabel). Only entries whose `label` actually differs from the - * `value` are kept, so the map is empty (→ `null`) when relabeling would be a + * nothing to relabel). Only entries whose display label actually differs from + * the key are kept, so the map is empty (→ `null`) when relabeling would be a * no-op and the caller can skip it entirely. + * + * **With a `translate` seam (objectui#4030) the map gains a SECOND key per + * option: the AUTHORED label.** A dimension's rows reach this net keyed either + * way — value-keyed when the server did not resolve the dimension (the whole + * reason this net exists), already label-keyed when it did (ADR-0021) — and + * the reported symptom is the second case: a chart legend showing the object's + * English `label` verbatim beside a related list showing the translation. One + * key resolves `orion`, the other re-translates `Orion Engineered Carbons`; + * `relabelDimensions` is value-wise and idempotent, so whichever the row + * carries lands on the same translated display. + * + * Value keys win over authored-label keys: a stored value that happens to + * equal some other option's label is still that option's value. + * + * Without a translator this is byte-for-byte the pre-#4030 map — the authored + * label then IS the display, so no second key is ever emitted. */ -export function buildDimensionLabelMap(options: unknown): Record | null { +export function buildDimensionLabelMap( + options: unknown, + translate?: OptionLabelTranslator, +): Record | null { if (!Array.isArray(options) || options.length === 0) return null; - const map: Record = {}; + const byValue: Record = {}; + const byAuthoredLabel: Record = {}; for (const opt of options) { - if (opt && typeof opt === 'object') { - const o = opt as { value?: unknown; label?: unknown }; - if (o.value != null && o.label != null) { - const v = String(o.value); - const l = String(o.label); - if (l !== v) map[v] = l; - } - } + const vl = optionValueLabel(opt); + if (!vl) continue; + const display = translate ? translate(vl.value, vl.label) : vl.label; + if (display !== vl.value) byValue[vl.value] = display; + if (display !== vl.label) byAuthoredLabel[vl.label] = display; } + const map = { ...byAuthoredLabel, ...byValue }; return Object.keys(map).length > 0 ? map : null; } @@ -365,13 +449,60 @@ export function resolveRelationshipTarget(fieldDef: unknown): string | undefined * cannot be loaded, or a terminal field that carries no `options` simply yields * no entry, and the caller keeps the raw value. Returns `{ fieldPath → options }` * for the paths that did resolve. + * + * Thin wrapper over {@link resolveDimensionFieldMeta}, which is the same ONE + * walk keeping the identity of what it found. Callers that translate option + * labels need that identity (the i18n key is + * `fieldOptions...`, and for a dotted path + * the owner is the RELATIONSHIP TARGET, not the dataset's base object) — see + * objectui#4030. */ export async function resolveDimensionFieldOptions( baseSchema: unknown, fieldPaths: Array, loadObjectSchema: (objectName: string) => Promise, ): Promise> { + const meta = await resolveDimensionFieldMeta(baseSchema, fieldPaths, loadObjectSchema); const out: Record = {}; + for (const [path, entry] of Object.entries(meta)) out[path] = entry.options; + return out; +} + +/** + * What {@link resolveDimensionFieldOptions} found for ONE dimension field path + * — the options AND the identity of the field they belong to. + */ +export interface DimensionFieldMeta { + /** + * The object that OWNS the terminal field: the dataset's base object for a + * local path, the last relationship's TARGET for a dotted one. `undefined` + * only when the base schema carries no `name` and the path is local. + */ + object: string | undefined; + /** The terminal field's own name — the LAST path segment, never the path. */ + field: string; + /** The terminal field's `options`, exactly as the metadata doc carries them. */ + options: unknown; +} + +/** + * {@link resolveDimensionFieldOptions} keeping what it walked THROUGH. + * + * Same single walk, same best-effort tolerance, same memoized loader — it just + * returns `{ object, field, options }` per resolved path instead of the options + * alone. Split out for objectui#4030: applying the locale bundle to a resolved + * option label needs the key the bundle is written under + * (`fieldOptions...`), and for `crm_account.industry` + * that object is `crm_account` — the walk already knows it and used to drop it + * on the floor. Deriving it at the call site would mean re-walking the + * relationship chain a second time, i.e. two derivations of one fact. + */ +export async function resolveDimensionFieldMeta( + baseSchema: unknown, + fieldPaths: Array, + loadObjectSchema: (objectName: string) => Promise, +): Promise> { + const out: Record = {}; const paths = Array.from(new Set((fieldPaths ?? []).filter((p): p is string => !!p))); if (paths.length === 0) return out; @@ -392,6 +523,9 @@ export async function resolveDimensionFieldOptions( for (const path of paths) { const segments = path.split('.'); let schema: unknown = baseSchema; + // The object owning the CURRENT schema — walked forward with it, so the + // terminal field's owner is whatever it holds when the walk ends. + let owner = typeof baseName === 'string' && baseName ? baseName : undefined; let walked = true; // Every segment but the last must be a relationship; walk to its target. // Hops are sequential by nature — hop N's object is only known once hop @@ -402,12 +536,15 @@ export async function resolveDimensionFieldOptions( // eslint-disable-next-line no-await-in-loop schema = await load(target); if (!schema) { walked = false; break; } + // Prefer the loaded doc's own `name` over the reference's spelling, so a + // reference written against an alias still keys the bundle canonically. + const loadedName = (schema as { name?: unknown }).name; + owner = typeof loadedName === 'string' && loadedName ? loadedName : target; } if (!walked) continue; - const terminal = fieldDefsOf(schema)?.[segments[segments.length - 1]] as - | { options?: unknown } - | undefined; - if (terminal?.options !== undefined) out[path] = terminal.options; + const field = segments[segments.length - 1]; + const terminal = fieldDefsOf(schema)?.[field] as { options?: unknown } | undefined; + if (terminal?.options !== undefined) out[path] = { object: owner, field, options: terminal.options }; } return out; } diff --git a/packages/plugin-charts/src/ObjectChart.tsx b/packages/plugin-charts/src/ObjectChart.tsx index b797f0b34..d82c97203 100644 --- a/packages/plugin-charts/src/ObjectChart.tsx +++ b/packages/plugin-charts/src/ObjectChart.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useContext, useCallback, useMemo, useRef } from 'react'; import { useDataScope, SchemaRendererContext, SchemaRenderer, useDrillNavigation, useFilterScope, ElementDataSourceGate, type ElementDataSourceMapping } from '@object-ui/react'; import { ChartRenderer } from './ChartRenderer'; -import { ComponentRegistry, extractRecords, computeDrillFilter, isDrillEnabled, resolveDrillTitle, resolveFilterPlaceholders, resolveContextTokens, shiftFilterByCompareTo, compareToTrendLabelKey, buildChartSeries, buildOptionColorMap, buildDimensionLabelMap, relabelDimensions, resolveDimensionFieldOptions, type CompareToConfig, type DrillEvent, type ChartResultField } from '@object-ui/core'; +import { ComponentRegistry, extractRecords, computeDrillFilter, isDrillEnabled, resolveDrillTitle, resolveFilterPlaceholders, resolveContextTokens, shiftFilterByCompareTo, compareToTrendLabelKey, buildChartSeries, buildOptionColorMap, buildDimensionLabelMap, relabelDimensions, localizeFieldOptions, resolveDimensionFieldMeta, type DimensionFieldMeta, type OptionLabelTranslator, type CompareToConfig, type DrillEvent, type ChartResultField } from '@object-ui/core'; import { Sheet, SheetContent, SheetHeader, SheetTitle, Dialog, DialogContent, DialogHeader, DialogTitle, RefreshIndicator, Button, ChartSkeleton } from '@object-ui/components'; import { AlertCircle, ArrowUpRight } from 'lucide-react'; import { useSafeFieldLabel, useSafeTranslate } from '@object-ui/i18n'; @@ -290,12 +290,20 @@ export const ObjectChart = (props: any) => { // the dimension field's option colors → {value|label → color} so the render // layer can use them. Keyed by BOTH value and label since the row category // may be either (server resolves dataset dimension labels). - const [fieldOptionColors, setFieldOptionColors] = useState | null>(null); - // Dataset path: {value → label} per dimension, so a value-keyed group (e.g. - // status=`active`) shows its option label (`合作中`) on the axis/legend with - // its count intact when the server returned raw values (cloud#667). The legacy - // objectName path already resolves labels via resolveGroupByLabels below. - const [dimensionLabels, setDimensionLabels] = useState> | null>(null); + // ── The label net's INPUT: resolved field metadata, locale-free ─────────── + // `{ object, field, options }` per resolved dimension field path, exactly as + // the metadata doc carries it. The colour map and the per-dimension + // {value → label} maps are DERIVED from it during render (one memo below), + // because that derivation is where the locale bundle applies — objectui#4030. + // Keeping the fetched metadata locale-free is what makes a language switch a + // re-render instead of a re-fetch. + const [optionMeta, setOptionMeta] = useState<{ + metaByPath: Record; + /** The colour dimension's field path — the aggregate groupBy / first dim. */ + colorPath: string; + /** Dataset path only: dimension name → its underlying field path. */ + fieldByDim: Record | null; + } | null>(null); // Host-provided "open in list" navigation for the drill escape hatch. const { openRecordList } = useDrillNavigation(); const tt = useSafeTranslate(); @@ -363,7 +371,7 @@ export const ObjectChart = (props: any) => { const dim = (datasetDef?.dimensions || []).find((d: any) => d?.name === dim0) ?? (datasetDef?.dimensions || [])[0]; fieldName = dim?.field ?? dim0; } - if (!objectName || !fieldName) { if (!cancelled) { setFieldOptionColors(null); setDimensionLabels(null); } return; } + if (!objectName || !fieldName) { if (!cancelled) setOptionMeta(null); return; } const loadObjectSchema = async (name: string) => { const r = await doFetch(`/api/v1/meta/object/${encodeURIComponent(name)}`, reqOpts); const doc = await r.json().catch(() => null); @@ -382,30 +390,63 @@ export const ObjectChart = (props: any) => { // One resolution for every path: a local field name reads straight off // `objSchema` as before, a dotted one walks to the object that owns the // terminal field. Unresolvable paths yield no entry → raw value survives. - const optionsByPath = await resolveDimensionFieldOptions( + // `…Meta` keeps the OWNING object + terminal field beside the options — + // the key the locale bundle is written under (objectui#4030). + const metaByPath = await resolveDimensionFieldMeta( objSchema, [fieldName, ...Object.values(fieldByDim)], loadObjectSchema, ); - const map = buildOptionColorMap(optionsByPath[fieldName]); - // dataset path: build a {value → label} map for EVERY select dimension - // (the objectName path resolves labels via resolveGroupByLabels instead). - let labels: Record> | null = null; - if (schema.dataset && Array.isArray(schema.dimensions)) { - const acc: Record> = {}; - for (const dimName of schema.dimensions) { - const m = buildDimensionLabelMap(optionsByPath[fieldByDim[dimName]]); - if (m) acc[dimName] = m; - } - if (Object.keys(acc).length > 0) labels = acc; + if (!cancelled) { + setOptionMeta({ + metaByPath, + colorPath: fieldName, + fieldByDim: schema.dataset && Array.isArray(schema.dimensions) ? fieldByDim : null, + }); } - if (!cancelled) { setFieldOptionColors(map); setDimensionLabels(labels); } - } catch { if (!cancelled) { setFieldOptionColors(null); setDimensionLabels(null); } } + } catch { if (!cancelled) setOptionMeta(null); } })(); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [schema.objectName, schema.dataset, datasetKey, aggregateKey, schema.xAxisKey, apiFetch]); + // ── The label net's OUTPUT, with the locale bundle applied (objectui#4030) ─ + // The net above RESOLVES a select dimension's option label; before this it + // handed the object's authored ENGLISH label straight to the axis and the + // legend, while the same page's related list showed the translation. The + // bundle is applied once, here, on the shared option list every consumer + // reads (colours AND the {value → label} relabel map) rather than per + // surface, through `fieldOptionLabel` — the resolver list and form surfaces + // already translate select options with + // (`fieldOptions...`). + const { fieldOptionColors, dimensionLabels } = useMemo(() => { + if (!optionMeta) return { fieldOptionColors: null, dimensionLabels: null }; + const { metaByPath, colorPath, fieldByDim } = optionMeta; + // Bound to the object that OWNS the terminal field — `crm_account` for + // `crm_account.industry`, not the dataset's base object. + const translatorFor = (path: string | undefined): OptionLabelTranslator | undefined => { + const meta = path ? metaByPath[path] : undefined; + const owner = meta?.object; + if (!owner) return undefined; + return (value, authored) => fieldOptionLabel(owner, meta.field, value, authored); + }; + // Colours read `option.label`, so they are fed the LOCALIZED options and + // stay keyed by the string the rendered category actually carries — on the + // aggregate path `resolveGroupByLabels` already translates it, so an + // untranslated colour map missed every category in a localized app. + const colorOptions = localizeFieldOptions(metaByPath[colorPath]?.options, translatorFor(colorPath)); + let labels: Record> | null = null; + if (fieldByDim) { + const acc: Record> = {}; + for (const [dimName, path] of Object.entries(fieldByDim)) { + const m = buildDimensionLabelMap(metaByPath[path]?.options, translatorFor(path)); + if (m) acc[dimName] = m; + } + if (Object.keys(acc).length > 0) labels = acc; + } + return { fieldOptionColors: buildOptionColorMap(colorOptions), dimensionLabels: labels }; + }, [optionMeta, fieldOptionLabel]); + // Run a single aggregate query (used for both the current and comparison // windows). Extracted so the two queries share identical logic. const runAggregate = useCallback(async (ds: any, filterForRun: any): Promise => { diff --git a/packages/plugin-dashboard/src/DatasetWidget.tsx b/packages/plugin-dashboard/src/DatasetWidget.tsx index 1a7bc109c..0a6951b42 100644 --- a/packages/plugin-dashboard/src/DatasetWidget.tsx +++ b/packages/plugin-dashboard/src/DatasetWidget.tsx @@ -34,7 +34,8 @@ import { buildDimensionLabelMap, buildCategoryOrder, relabelDimensions, - resolveDimensionFieldOptions, + localizeFieldOptions, + resolveDimensionFieldMeta, findChartSeriesRow, formatMeasure, formatDimensionValue, @@ -52,6 +53,8 @@ import { pivotCellKey, compareToTrendLabelKey, type ChartSeriesBinding, + type DimensionFieldMeta, + type OptionLabelTranslator, type CompareToConfig, type DatasetResultField, type DatasetDrillRange, @@ -689,7 +692,7 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource: : undefined; const tt = useSafeTranslate(); - const { fieldLabel } = useSafeFieldLabel(); + const { fieldLabel, fieldOptionLabel } = useSafeFieldLabel(); // ADR-0021 dual-form: the widget's presentation-scope `filter` must flow into // the dataset query as `runtimeFilter`, or a dataset-bound widget renders the @@ -738,25 +741,23 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource: const [state, setState] = useState<{ status: 'idle' | 'loading' | 'ok' | 'error'; rows: Row[]; fields?: DatasetResultField[]; object?: string; dimensionFields?: Record; drillRawRows?: Array>; drillRanges?: Array>; totals?: DatasetTotals[]; error?: string }>({ status: 'idle', rows: [] }); // Drill-through (ADR-0021 D2): the clicked bucket's record-list filter + title. const [drill, setDrill] = useState<{ filter: Record; title: string } | null>(null); - // Per-category colors: when the chart's first dimension is a select/lookup - // field, paint each category in its option color (health green/red/yellow) - // instead of the generic --chart-1..5 palette — the same wiring the chart - // view uses (ObjectChart). The renderer's `categoryColors` map wins over the - // positional palette and falls back to it for categories without a color. - const [categoryColors, setCategoryColors] = useState | null>(null); - // Per-dimension {value → label} maps. The dataset groups by a select field's - // stored value (e.g. `active`); the chart axis must read the option label - // (e.g. `合作中`). The server resolves this when it can, but an AI-built - // select whose options the analytics layer can't see comes back value-keyed, - // so we resolve it here from the object field options (see relabelDimensions). - const [dimensionLabels, setDimensionLabels] = useState> | null>(null); - // The first dimension's DECLARED picklist order (framework#3588) — the - // sequence the author wrote the options in on the object. For an - // ordered-sequence chart (funnel/pyramid) that order IS the domain order - // (Qualification → Needs Analysis → Proposal → Negotiation); grouping returns - // buckets alphabetically, which draws a shape that reads as a pipeline but - // isn't one. Fetched from the same object schema as the colours/labels below. - const [categoryOrder, setCategoryOrder] = useState(null); + // ── The analytics label net's INPUT: resolved field metadata, locale-free ── + // What the object-schema probe below found for this widget's dimensions — + // the raw `{ object, field, options }` per dimension field path, exactly as + // the metadata doc carries it. Everything the surface actually displays + // (per-category colours, {value → label} maps, the declared category order) + // is DERIVED from it during render, one memo down, because that derivation + // is where the locale bundle applies (objectui#4030): keeping the fetched + // metadata locale-free means switching language re-renders the labels + // instead of re-fetching the schema, and the i18n application sits at the + // net's output rather than inside its fetch. + const [optionMeta, setOptionMeta] = useState<{ + metaByPath: Record; + /** The dimensions this widget relabels, paired with their field paths. */ + relabel: Array<{ dim: string; path: string }>; + /** First dimension's path — chart wiring only; undefined on table/pivot. */ + firstDimPath?: string; + } | null>(null); // Signature uses the RAW filter (stable) — the resolved one carries a // render-time `now` and would otherwise force a refetch loop. The @@ -810,7 +811,7 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource: // label — a dimension's value never reaches its output in any spelling — so // there is nothing on that path to relabel and resolving would be a // metadata read nothing consumes (objectui#4263, pinned). - if (isMetric || !object || dimensions.length === 0) { setCategoryColors(null); setDimensionLabels(null); setCategoryOrder(null); return; } + if (isMetric || !object || dimensions.length === 0) { setOptionMeta(null); return; } const fieldOf = (dim: string) => (state.dimensionFields && state.dimensionFields[dim]) || dim; // ── Which dimensions this widget type resolves (objectui#4263) ────────── // On table/pivot the SERVER resolves a dimension's display label @@ -828,7 +829,7 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource: const resolveDims = dottedOnly ? dimensions.filter((d) => fieldOf(d).includes('.')) : dimensions; // A table with no dotted dimension resolves nothing and — the part that // makes "unchanged" literal — never issues the metadata read at all. - if (resolveDims.length === 0) { setCategoryColors(null); setDimensionLabels(null); setCategoryOrder(null); return; } + if (resolveDims.length === 0) { setOptionMeta(null); return; } let cancelled = false; (async () => { try { @@ -841,33 +842,30 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource: const objSchema = await loadObjectSchema(object); // A dimension's field may be a DOTTED relationship path // (`crm_account.industry`) whose options live on the RELATED object, not - // on the dataset's base object — objectui#4053. `resolveDimensionFieldOptions` + // on the dataset's base object — objectui#4053. `resolveDimensionFieldMeta` // is the object-resolution step of this same lookup: a local field name // still resolves straight off `objSchema`, a dotted one walks each hop // through the SAME channel `objSchema` came from. Unresolvable paths - // yield no entry, so the raw value survives exactly as before. - const optionsByPath = await resolveDimensionFieldOptions( + // yield no entry, so the raw value survives exactly as before. It keeps + // the OWNING object and terminal field name beside the options, which + // is the key the locale bundle is written under (objectui#4030). + const metaByPath = await resolveDimensionFieldMeta( objSchema, resolveDims.map(fieldOf), loadObjectSchema, ); - // Per-category COLOURS and the declared category ORDER are chart wiring - // — they key the palette and the axis sequence, neither of which a - // table or pivot renders. They stay null on that path exactly as they - // did when it returned early (objectui#4263). - const firstDimOptions = dottedOnly ? undefined : optionsByPath[fieldOf(dimensions[0])]; - const colorMap = buildOptionColorMap(firstDimOptions); - const labels: Record> = {}; - for (const dim of resolveDims) { - const m = buildDimensionLabelMap(optionsByPath[fieldOf(dim)]); - if (m) labels[dim] = m; - } if (!cancelled) { - setCategoryColors(colorMap); - setDimensionLabels(Object.keys(labels).length > 0 ? labels : null); - setCategoryOrder(buildCategoryOrder(firstDimOptions)); + setOptionMeta({ + metaByPath, + relabel: resolveDims.map((dim) => ({ dim, path: fieldOf(dim) })), + // Per-category COLOURS and the declared category ORDER are chart + // wiring — they key the palette and the axis sequence, neither of + // which a table or pivot renders. They stay null on that path + // exactly as they did when it returned early (objectui#4263). + firstDimPath: dottedOnly ? undefined : fieldOf(dimensions[0]), + }); } - } catch { if (!cancelled) { setCategoryColors(null); setDimensionLabels(null); setCategoryOrder(null); } } + } catch { if (!cancelled) setOptionMeta(null); } })(); return () => { cancelled = true; }; // `apiFetch` joins the deps (objectui#4121) exactly as it does in @@ -880,6 +878,49 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource: // The in-repo host holds it at module scope (`ConsoleShell.tsx:187` → `:245`). }, [state.object, state.dimensionFields, dimensions, isMetric, isTable, apiFetch]); + // ── The analytics label net's OUTPUT, with the locale bundle applied ────── + // objectui#4030 (source thread objectstack#5076): the net above RESOLVES a + // select dimension's option label but used to hand the object's authored + // English label straight to the axis, the legend, the table cells and the + // CSV — the same screen's related list showing the translation beside it. + // The bundle is applied HERE, once, on the shared `{value → label}` / + // localized-options pair every one of those consumers reads, rather than per + // surface. `fieldOptionLabel` is the resolver list and form surfaces already + // use (`fieldOptions...`), reached through the + // provider-safe wrapper so a widget rendered without an I18nProvider keeps + // its authored labels instead of crashing. + const { categoryColors, dimensionLabels, categoryOrder } = useMemo(() => { + if (!optionMeta) return { categoryColors: null, dimensionLabels: null, categoryOrder: null }; + const { metaByPath, relabel, firstDimPath } = optionMeta; + // One translator per resolved path, bound to the object that OWNS the + // terminal field — `crm_account` for `crm_account.industry`, not the + // dataset's base object. A path whose owner could not be resolved gets no + // translator and keeps its authored labels. + const translatorFor = (path: string | undefined): OptionLabelTranslator | undefined => { + const meta = path ? metaByPath[path] : undefined; + const owner = meta?.object; + if (!owner) return undefined; + return (value, authored) => fieldOptionLabel(owner, meta.field, value, authored); + }; + // Colours and declared order read `option.label`, so they are fed the + // LOCALIZED options — the same "translate the options, then render them" + // shape the list side uses (`translateOptions` → `SelectCellRenderer`). + // That keeps them keyed by the string the relabeled rows actually carry. + const firstDimOptions = firstDimPath + ? localizeFieldOptions(metaByPath[firstDimPath]?.options, translatorFor(firstDimPath)) + : undefined; + const labels: Record> = {}; + for (const { dim, path } of relabel) { + const m = buildDimensionLabelMap(metaByPath[path]?.options, translatorFor(path)); + if (m) labels[dim] = m; + } + return { + categoryColors: buildOptionColorMap(firstDimOptions), + dimensionLabels: Object.keys(labels).length > 0 ? labels : null, + categoryOrder: buildCategoryOrder(firstDimOptions), + }; + }, [optionMeta, fieldOptionLabel]); + if (values.length === 0) { return
{tt('dashboard.pickMeasures', 'Pick measures (values) for this dataset widget.')}
; } diff --git a/packages/plugin-dashboard/src/__tests__/DatasetWidget.optionLabelI18n.test.tsx b/packages/plugin-dashboard/src/__tests__/DatasetWidget.optionLabelI18n.test.tsx new file mode 100644 index 000000000..167b263ed --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/DatasetWidget.optionLabelI18n.test.tsx @@ -0,0 +1,351 @@ +/** + * 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#4030 (source thread objectstack#5076) — a dashboard widget resolves + * a `select` dimension's option label and then renders the object's authored + * ENGLISH label, while the related list on the same page renders the zh-CN + * translation. One screen, one value, two spellings. + * + * The reporter's own fixture is used verbatim, because its decisive row is + * `orion`: the chart showed `Orion Engineered Carbons`, a string with no + * resemblance to the stored value, matching the object's `label` exactly. So + * the label WAS resolved — it just never went through the locale bundle. (The + * `domestic → Domestic` row differs from its value by case alone, which is why + * this was first mis-diagnosed as "the report groups by stored value"; it is + * kept here as the second dimension of the pivot case for that reason.) + * + * WHICH CHANNEL. There is exactly one, and it is not new: `fieldOptionLabel` + * from `useObjectLabel` — `{ns}.fieldOptions...`, the + * convention `@objectstack/spec` names objectui as the reader of, and the one + * list and form surfaces already translate select options through + * (`translateOptions` → `SelectCellRenderer`; objectui#3336 pinned the same + * "one source, two faces" property for the record picker). Analytics reuses it + * at the label net's output rather than growing a chart-side dialect. + * + * DIRECTIONS, written before the reverse verification was run: + * - the three zh-CN cases are RED before the change (they render the authored + * English label — that IS the bug); + * - the `en` case, the no-bundle-entry case and the drill-identity case are + * GREEN on both sides. They are the acceptance boundary: an app with no + * translation must keep the exact label it renders today (the card's + * "downstream impact" is precisely about not losing it), and the STORED + * value must keep addressing the data (display translates, identity keys do + * not — objectui#4263's convention). + */ + +import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest'; +import { render, cleanup, screen, waitFor } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { I18nProvider } from '@object-ui/i18n'; +import { DatasetWidget } from '../DatasetWidget'; + +/** Capture what the widget hands the chart renderer (jsdom lays out no SVG). */ +let capturedChartProps: any = null; +beforeAll(() => { + ComponentRegistry.register('chart', (props: any) => { + capturedChartProps = props; + return null; + }); +}); + +/** Observe the drill filter without rendering the real drawer. */ +const drillFilters: Array> = []; +vi.mock('../DrillDownDrawer', () => ({ + DrillDownDrawer: ({ filter }: { filter: Record }) => { + drillFilters.push(filter); + return null; + }, +})); + +afterEach(() => { + cleanup(); + capturedChartProps = null; + drillFilters.length = 0; + vi.restoreAllMocks(); +}); + +/** The card's field, on the card's object. */ +const COMPETITOR_OPTIONS = [ + { value: 'cabot', label: 'Cabot' }, + { value: 'orion', label: 'Orion Engineered Carbons' }, + // No bundle entry — the untranslated-option boundary. + { value: 'birla', label: 'Birla Carbon' }, +]; +const CHANNEL_OPTIONS = [ + { value: 'domestic', label: 'Domestic' }, + { value: 'export', label: 'Export' }, +]; + +const OPPORTUNITY = { + name: 'crm_opportunity', + fields: { + competitor_name: { type: 'select', options: COMPETITOR_OPTIONS }, + sales_channel: { type: 'select', options: CHANNEL_OPTIONS }, + crm_account: { type: 'lookup', reference: 'crm_account' }, + }, +}; +/** The relationship target — where a DOTTED dimension's options really live. */ +const ACCOUNT = { + name: 'crm_account', + fields: { industry: { type: 'select', options: [{ value: 'education', label: 'Education' }] } }, +}; + +/** + * The reporter's zh-CN bundle. `fields` is present alongside `fieldOptions` + * because that is what makes `crm` discoverable as an app namespace (a real + * bundle carries both) — see `getAppNamespaces`. + */ +const ZH_BUNDLE = { + zh: { + crm: { + fields: { + crm_opportunity: { competitor_name: '竞争对手', sales_channel: '销售渠道' }, + crm_account: { industry: '行业' }, + }, + fieldOptions: { + crm_opportunity: { + competitor_name: { cabot: '卡博特', orion: '欧励隆' }, + sales_channel: { domestic: '国内', export: '出口' }, + }, + // Keyed by the object that OWNS the field, which for the dotted + // dimension below is the relationship TARGET, not the dataset's base. + crm_account: { industry: { education: '教育' } }, + }, + }, + }, +}; + +function installMetaRouter(docs: Record) { + const requested: string[] = []; + global.fetch = vi.fn(async (input: unknown) => { + const url = String(input); + const m = /\/api\/v1\/meta\/object\/(.+)$/.exec(url); + const name = m ? decodeURIComponent(m[1]) : ''; + requested.push(name); + const doc = docs[name]; + if (!doc) return { ok: false, json: async () => ({}) }; + return { ok: true, json: async () => ({ item: doc }) }; + }) as any; + return { requested }; +} + +const sourceOf = (result: unknown) => ({ queryDataset: vi.fn(async () => result) }); + +/** A value-keyed chart result — the server did NOT resolve the dimension. */ +const valueKeyedChart = () => + sourceOf({ + rows: [ + { competitor_name: 'orion', deals: 7 }, + { competitor_name: 'cabot', deals: 3 }, + { competitor_name: 'birla', deals: 1 }, + ], + fields: [ + { name: 'competitor_name', type: 'select', label: 'Competitor' }, + { name: 'deals', type: 'number', label: 'Deals' }, + ], + object: 'crm_opportunity', + dimensionFields: { competitor_name: 'competitor_name' }, + drillRawRows: [ + { competitor_name: 'orion' }, + { competitor_name: 'cabot' }, + { competitor_name: 'birla' }, + ], + }); + +/** The same result AFTER the server resolved the dimension (ADR-0021). */ +const serverResolvedChart = () => + sourceOf({ + rows: [ + { competitor_name: 'Orion Engineered Carbons', deals: 7 }, + { competitor_name: 'Cabot', deals: 3 }, + ], + fields: [ + { name: 'competitor_name', type: 'select', label: 'Competitor' }, + { name: 'deals', type: 'number', label: 'Deals' }, + ], + object: 'crm_opportunity', + dimensionFields: { competitor_name: 'competitor_name' }, + drillRawRows: [{ competitor_name: 'orion' }, { competitor_name: 'cabot' }], + }); + +function renderIn(language: string, ui: React.ReactElement) { + return render( + + {ui} + , + ); +} + +const categories = () => (capturedChartProps?.schema?.data ?? []).map((r: any) => r.competitor_name); + +describe('DatasetWidget chart — select-option labels run through the i18n bundle (objectui#4030)', () => { + it('renders the zh-CN option label on a VALUE-keyed chart', async () => { + installMetaRouter({ crm_opportunity: OPPORTUNITY, crm_account: ACCOUNT }); + renderIn( + 'zh', + , + ); + await waitFor(() => expect(categories()).toContain('欧励隆')); + expect(categories()).toContain('卡博特'); + // The authored English label must not survive beside the translation. + expect(categories()).not.toContain('Orion Engineered Carbons'); + }); + + it('re-translates a chart the SERVER already resolved to the English label', async () => { + // This is the reported screen: the rows arrive carrying the object's + // authored label verbatim. A value-keyed map alone cannot touch them, so a + // fix that only handles the un-resolved case would leave the issue open. + installMetaRouter({ crm_opportunity: OPPORTUNITY, crm_account: ACCOUNT }); + renderIn( + 'zh', + , + ); + await waitFor(() => expect(categories()).toContain('欧励隆')); + expect(categories()).not.toContain('Orion Engineered Carbons'); + }); + + it('keeps the measure attached to its (now translated) category', async () => { + installMetaRouter({ crm_opportunity: OPPORTUNITY, crm_account: ACCOUNT }); + renderIn( + 'zh', + , + ); + await waitFor(() => expect(categories()).toContain('欧励隆')); + const byCategory = Object.fromEntries( + capturedChartProps.schema.data.map((r: any) => [r.competitor_name, r.deals]), + ); + expect(byCategory).toMatchObject({ 欧励隆: 7, 卡博特: 3 }); + }); + + it('BOUNDARY — an option the bundle does not carry keeps its authored label', async () => { + // Green on both sides. The card's downstream-impact section is exactly + // about not trading the English label away for the locales that have none. + installMetaRouter({ crm_opportunity: OPPORTUNITY, crm_account: ACCOUNT }); + renderIn( + 'zh', + , + ); + // Deliberately NOT sequenced on the translated category: waiting for `欧励隆` + // here would make this pin fail with the others when the seam is removed, + // and a boundary that cannot stay green through the reverse verification is + // not pinning a boundary. `Birla Carbon` is itself the resolved-label + // signal — the raw rows carry `birla`. + await waitFor(() => expect(categories()).toContain('Birla Carbon')); + expect(categories()).not.toContain('birla'); + }); + + it('BOUNDARY — under `en` the chart reads exactly what it reads today', async () => { + // Green on both sides: the same bundle is mounted, and an `en` console must + // still see the object's authored labels. + installMetaRouter({ crm_opportunity: OPPORTUNITY, crm_account: ACCOUNT }); + renderIn( + 'en', + , + ); + await waitFor(() => expect(categories()).toContain('Orion Engineered Carbons')); + expect(categories()).not.toContain('欧励隆'); + }); + + it('BOUNDARY — a segment click still drills by the STORED value, whatever it displays', async () => { + // Display translates; identity keys do not (objectui#4263's convention). + // The click is made with WHATEVER the first category reads — `欧励隆` with + // the seam in place, `Orion Engineered Carbons` without it — so this pin + // holds green in both directions and is about the filter alone. + installMetaRouter({ crm_opportunity: OPPORTUNITY, crm_account: ACCOUNT }); + renderIn( + 'zh', + , + ); + await waitFor(() => expect(categories()).toContain('Birla Carbon')); + // The click arrives with the DISPLAYED category — the chart knows no other. + const displayed = categories()[0]; + expect(displayed).not.toBe('orion'); + capturedChartProps.onSegmentClick({ category: displayed }); + await waitFor(() => expect(drillFilters.length).toBeGreaterThan(0)); + expect(drillFilters[drillFilters.length - 1]).toMatchObject({ competitor_name: 'orion' }); + }); +}); + +describe('DatasetWidget table/pivot — the dotted gap-fill translates too (objectui#4030 × #4263)', () => { + it('renders the zh-CN label for a DOTTED dimension, keyed by the RELATIONSHIP TARGET', async () => { + // The bundle entry lives under `crm_account.industry`, not + // `crm_opportunity.`. Keying it against the dataset's base + // object — or against the path as though it were a field name — resolves to + // nothing, so this case is what pins the owner the walk resolved. + const { requested } = installMetaRouter({ crm_opportunity: OPPORTUNITY, crm_account: ACCOUNT }); + renderIn( + 'zh', + , + ); + await waitFor(() => expect(screen.getByText('教育')).toBeTruthy()); + // It got there through the walk #4261 already performs — no second channel. + expect(requested).toEqual(['crm_opportunity', 'crm_account']); + }); + + it('BOUNDARY — a LOCAL-only table still resolves nothing and fetches nothing', async () => { + // #4263's acceptance boundary, restated under a mounted bundle: the client + // net stays OFF for a local dimension on a table (the server owns that + // label there), so no metadata read is issued and the server's string is + // rendered untouched. Green on both sides — this change adds no new + // resolution, only a translation of the one that already ran. + const { requested } = installMetaRouter({ crm_opportunity: OPPORTUNITY, crm_account: ACCOUNT }); + renderIn( + 'zh', + , + ); + await waitFor(() => expect(screen.getByText('Orion Engineered Carbons')).toBeTruthy()); + expect(requested).toEqual([]); + }); +});