From 867d0268be947c3201639dfa918bb925bd0ae32e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 00:12:05 +0000 Subject: [PATCH 1/3] fix(dashboard,report): localize a LOCAL select dimension on table/pivot and the dataset report (#4330) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dashboard table grouped by a select field rendered `Domestic` on a zh-CN console while the related list beside it rendered 国内. The label was not missing — the server resolves it (ADR-0021) and sends the object's AUTHORED English label — and the locale bundle is keyed by the option's stored VALUE, so translating one needs the option LIST. objectui#4263's landed pin asserted that this path issues no metadata read at all, so there was nothing on the client to translate against; PR #4324 measured that and left the half open. Table, pivot and `DatasetReportRenderer` now take that one read and feed it to the SAME seam #4324 landed (`resolveDimensionFieldMeta` → `localizeFieldOptions` / `buildDimensionLabelMap` → `relabelDimensions`). One channel, no second dialect. Applied at the shared map every consumer reads: cells, both pivot axes, the server's marginal totals (relabeled so their bucket lookup still meets the headers), the CSV export, and a report's embedded chart. The read is deliberately NOT gated on "a select dimension is present": `DatasetDimension.type` has no `select` member, every select dimension in the live example apps declares `type: 'string'`, and a select column arrives on the wire typed `'string'`. The gate lands on the read's OUTPUT instead, where it is exact — `resolveDimensionFieldMeta` yields an entry only for a terminal field that carries `options`. Identity keys are untouched: drills still filter by the values the server sent (`drillRawRows` / `groupKey` / `objectFilter`), and measures still export as bare numbers. Untranslated apps are unchanged by construction — no bundle entry means no map key, so the rows come back by identity. #4263's no-metadata-read pin, and #4324's restatement of it, are rewritten in place in this commit to assert the new boundary and to say why it moved. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 --- .../local-select-dimension-i18n-4330.md | 14 + .../plugin-dashboard/src/DatasetWidget.tsx | 93 ++-- ...atasetWidget.dottedDimensionTable.test.tsx | 104 ++-- .../DatasetWidget.localSelectI18n.test.tsx | 445 ++++++++++++++++++ .../DatasetWidget.optionLabelI18n.test.tsx | 33 +- .../src/DatasetReportRenderer.tsx | 85 +++- ...setReportRenderer.localSelectI18n.test.tsx | 341 ++++++++++++++ .../src/useDatasetDimensionLabels.ts | 159 +++++++ 8 files changed, 1187 insertions(+), 87 deletions(-) create mode 100644 .changeset/local-select-dimension-i18n-4330.md create mode 100644 packages/plugin-dashboard/src/__tests__/DatasetWidget.localSelectI18n.test.tsx create mode 100644 packages/plugin-report/src/__tests__/DatasetReportRenderer.localSelectI18n.test.tsx create mode 100644 packages/plugin-report/src/useDatasetDimensionLabels.ts diff --git a/.changeset/local-select-dimension-i18n-4330.md b/.changeset/local-select-dimension-i18n-4330.md new file mode 100644 index 000000000..6c6e6863a --- /dev/null +++ b/.changeset/local-select-dimension-i18n-4330.md @@ -0,0 +1,14 @@ +--- +'@object-ui/plugin-dashboard': patch +'@object-ui/plugin-report': patch +--- + +Analytics: a LOCAL select dimension on a table / pivot widget — and on a dataset-bound report — now renders its option label through the locale bundle + +A dashboard table grouped by a select field showed `Domestic` on a zh-CN console while the related list on the same screen showed 国内. The value was never untranslated by accident: the server resolves that dimension's display label (ADR-0021) and hands the row over carrying the object's AUTHORED English label. The locale bundle is keyed by the option's stored VALUE (`{ns}.fieldOptions...`), so translating one needs the option LIST — and the table path deliberately loaded no object metadata at all, which is why objectui#4030 / PR #4324 fixed charts and dotted dimensions and left this half open. + +Table, pivot and the dataset report block now take the one metadata read that gives the bundle something to translate against, and feed it to the SAME seam #4324 landed (`resolveDimensionFieldMeta` → `localizeFieldOptions` / `buildDimensionLabelMap` → `relabelDimensions`). No second resolution dialect: the map carries both the stored value and the authored label as keys, and the relabel is value-wise and idempotent, so a value the server already resolved lands on the same display it would have from the raw value. Cells, pivot headers on both axes, the server's marginal totals, the CSV export and a report's embedded chart all read the one map, which is what keeps a subtotal's bucket lookup meeting the header it belongs to. + +Untranslated apps are unchanged by construction: with no bundle entry the display equals the authored label, no key is emitted, and the rows come back by identity. Identity keys stay untranslated — a drilled row or cell still filters records by the values the server sent, and measures still export as bare numbers. + +This deliberately amends the acceptance boundary objectui#4263 landed ("a local-only table issues no metadata read"), which was ruled for label RESOLUTION before the read had a second consumer. The pins that stated it are rewritten in place, in the same change, and say so. diff --git a/packages/plugin-dashboard/src/DatasetWidget.tsx b/packages/plugin-dashboard/src/DatasetWidget.tsx index 0a6951b42..94ead0bdf 100644 --- a/packages/plugin-dashboard/src/DatasetWidget.tsx +++ b/packages/plugin-dashboard/src/DatasetWidget.tsx @@ -790,12 +790,13 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource: // eslint-disable-next-line react-hooks/exhaustive-deps }, [signature]); - // Resolve the dimensions' select/lookup field options (charts only; - // metric/table don't use per-category colors or axis relabeling). The dataset - // query gives us the base `object` + the dimension→field map, so ONE object - // schema fetch yields both: a {value|label → color} map for the first - // dimension's per-category colors, and a {value → label} map per dimension so - // the axis/series display labels even when the server returned raw values. + // Resolve the dimensions' select/lookup field options. The dataset query + // gives us the base `object` + the dimension→field map, so ONE object schema + // fetch yields both: a {value|label → color} map for the first dimension's + // per-category colors (charts only — a table renders no palette), and a + // {value → label} map per dimension so the axis/series/cells display labels + // even when the server returned raw values, and so the locale bundle has an + // option list to translate against (objectui#4030 / #4330). // Best-effort: any failure leaves both null (positional palette + raw values). // // The read rides the host's AUTHENTICATED fetch (objectui#4121) — the same @@ -813,22 +814,42 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource: // metadata read nothing consumes (objectui#4263, pinned). 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 - // (ADR-0021) — that is exactly why objectui#4053's `table` widget rendered - // `Education` while its chart rendered `education`. So this client net - // stays OFF for LOCAL dimensions there: running it would be a SECOND - // resolution of a value the server already resolved. It opens only for - // DOTTED paths, the case the server is silent on too (#4053's premise), - // where this is the only resolution available and the table would - // otherwise show the raw stored enum. + // ── Which dimensions this widget type resolves (objectui#4263 → #4330) ── + // EVERY dimension, on every non-metric widget type — one rule, no + // per-widget-type dialect. // - // Charts keep resolving EVERY dimension: the server's silence for an - // AI-built select is the whole reason this net exists there. - const dottedOnly = isTable; - 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. + // #4263 ruled a narrower one: on table/pivot the SERVER resolves a LOCAL + // dimension's display label (ADR-0021), so this client net stayed off + // there and opened only for DOTTED paths, where the server is silent too. + // That boundary was ruled for LABEL RESOLUTION — "the label already + // exists, don't produce it twice" — and it held exactly as long as + // resolution was the only thing downstream of the read. objectui#4030 / + // PR #4324 put a second consumer there: the locale bundle + // (`localizeFieldOptions` / `buildDimensionLabelMap`'s translator), which + // needs the option LIST, not the label. So a local select on a table had + // its label resolved by the server, in English, with nothing on the client + // to translate it against — the cells read `Domestic` beside a related + // list reading 国内 (objectui#4330). The read is what closes that, and the + // PM amended the #4263 boundary deliberately for it. + // + // It is not a second resolution: `buildDimensionLabelMap` carries BOTH the + // stored value and the AUTHORED label as keys, and `relabelDimensions` is + // value-wise and idempotent — a server-resolved `Domestic` maps to 国内 + // once, and under `en` (or with no bundle entry) the display equals the + // authored label, so no key is emitted and the rows pass through by + // identity. + // + // WHY THE READ IS NOT GATED ON "the dimension is a select" (measured, see + // the amended pins): select-ness is not observable before the read. + // `DatasetDimension.type` is `string|number|date|boolean|lookup` — the spec + // has no `select` member — and every select dimension in the live example + // apps declares `type: 'string'`; on the wire, `AnalyticsResult.fields[]` + // types a select column `'string'` too (the analytics cube registry's + // `fieldTypeToDimensionType` default). The select gate therefore lands on + // the read's OUTPUT, where it is exact: `resolveDimensionFieldMeta` yields + // an entry only for a terminal field that actually carries `options`, so a + // text / number / date / lookup dimension produces no map and no relabel. + const resolveDims = dimensions; if (resolveDims.length === 0) { setOptionMeta(null); return; } let cancelled = false; (async () => { @@ -861,8 +882,10 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource: // 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]), + // exactly as they did when it returned early (objectui#4263), and + // #4330 widened only WHICH DIMENSIONS get a label map, never what + // a table consumes. + firstDimPath: isTable ? undefined : fieldOf(dimensions[0]), }); } } catch { if (!cancelled) setOptionMeta(null); } @@ -1078,16 +1101,16 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource: // Table / pivot — a grouped table or, for a pivot with ≥2 dimensions, a true // cross-tab. if (isTable) { - // ── The dotted gap-fill's display rows (objectui#4263) ────────────────── - // The table/pivot branch renders `state.rows` as they arrived, which is - // correct for a LOCAL dimension (the server resolved its label) and leaves - // a DOTTED one showing the raw stored enum, since nothing resolved it. - // `dimensionLabels` is populated on this path for DOTTED dimensions ONLY - // (see the resolution effect), so for a table whose dimensions are all - // local it is null and `relabelDimensions` returns `state.rows` ITSELF — - // the same array identity, hence the same rendered bytes as before this - // change. It is value-keyed and idempotent besides, so a value the server - // already resolved has no entry and passes through untouched. + // ── The display rows (objectui#4263, widened by #4330) ────────────────── + // The table/pivot branch renders `state.rows` as they arrived unless a + // dimension resolved a label map. `dimensionLabels` covers EVERY dimension + // whose terminal field carries `options` — dotted (the server resolved + // nothing) and local (the server resolved the AUTHORED label, which under + // a non-default locale is the wrong one, objectui#4330). A dimension whose + // field carries no options gets no entry, so a text/number/date/lookup + // table still gets `state.rows` ITSELF back — same array identity, same + // rendered bytes. The map is value-AND-authored-label keyed and + // `relabelDimensions` is idempotent, so neither spelling double-resolves. // // Row ORDER and COUNT are preserved, which is what keeps `openDrill(i)` // and `pivot.cellIndex` aligned with the raw `drillRawRows` they index @@ -1127,6 +1150,10 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource: // The CSV follows the table's own cells (objectui#4263): a dotted // dimension exports the label the table now shows, and a local one is // unchanged for the same reason its cell is. + // …and #4330 widens that to a LOCAL select dimension for the same + // reason: the CSV is the table's data, so it exports the cell. Measures + // stay numeric (the raw values that must round-trip into a spreadsheet); + // the drill filter — the identity key — reads `drillRawRows`, untouched. ...displayRows.map((r) => exportColumns.map((c) => { const v = r[c]; return v == null ? '' : (typeof v === 'number' ? v : String(v)); diff --git a/packages/plugin-dashboard/src/__tests__/DatasetWidget.dottedDimensionTable.test.tsx b/packages/plugin-dashboard/src/__tests__/DatasetWidget.dottedDimensionTable.test.tsx index 813f011bc..fca633f6b 100644 --- a/packages/plugin-dashboard/src/__tests__/DatasetWidget.dottedDimensionTable.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/DatasetWidget.dottedDimensionTable.test.tsx @@ -12,24 +12,50 @@ * or `pivot` a dotted dimension still rendered the raw stored enum * (`education`), which is exactly the symptom #4053 reports for charts. * - * The ruling on #4263 is a DOTTED-ONLY GAP-FILL, and these pins are shaped to - * hold both halves of it apart: + * The ruling on #4263 was a DOTTED-ONLY GAP-FILL: * * - For a LOCAL dimension the server resolves the display label (ADR-0021) — * that is why #4053's widget B (a `table`) rendered `Education` correctly. - * The client net must stay OFF there, or a table would double-resolve a - * label the server already produced. Pinned by `renders a LOCAL dimension - * exactly as the server sent it` and by the local column of the mixed test. + * The client net stayed OFF there, so a table would not double-resolve a + * label the server already produced — and, the part that made "unchanged" + * literal, issued NO metadata read at all. * - For a DOTTED dimension the server is silent too (that is #4053's premise), * so the value reaches the table unresolved and the client net is the only * resolution available. Pinned red-first below. * - * The mixed test puts BOTH on ONE table widget for the same reason the #4053 - * suite does: the two dimensions carry the SAME option set, so any difference - * between the two rendered cells is the bug and nothing else — and an - * implementation that simply switched the early return off (resolving local - * dimensions on tables too) fails it, which a dotted-only fixture could not - * detect. + * ## ⚠️ THE LOCAL HALF OF THAT BOUNDARY IS AMENDED — objectui#4330 + * + * The no-metadata-read half was an acceptance boundary for label RESOLUTION, + * ruled in that context: "the label already exists, do not produce it twice." + * It held exactly as long as resolution was the only consumer of the read. + * objectui#4030 / PR #4324 added a second one — the LOCALE BUNDLE, which is + * keyed by the option's stored value and therefore needs the option LIST, not + * the label. Under that pin a zh-CN console rendered `Domestic` in a table cell + * while the related list beside it rendered 国内, with nothing on the client to + * translate against (objectui#4330). + * + * So the PM amended it deliberately, in the PR that uses it: a table / pivot + * takes the ONE read needed to feed the SAME seam #4324 landed, for EVERY + * dimension. Concretely, in this file: + * + * - the LOCAL cell of the mixed test now resolves too — it was pinned as + * `education` (raw, because that fixture models a server that did not + * resolve it) and now reads `Education`; + * - `a LOCAL-only table … resolving nothing` becomes `… issues the ONE read`: + * it still renders the server's string untouched under `en`, but the read + * that makes it translatable is now expected rather than forbidden; + * - the CSV follows its table's cells, as it always has. + * + * What did NOT change, and is still pinned here: the dotted walk itself, the + * multi-hop walk, the METRIC branch's silence (it renders no dimension value, + * so it resolves nothing and reads nothing), and identity keys — a drill still + * filters by the stored value. `DatasetWidget.localSelectI18n.test.tsx` is the + * new behaviour's own suite; this file keeps the #4263 shape so the amendment + * is legible as a diff. + * + * The mixed test still puts BOTH dimension kinds on ONE table widget for the + * reason the #4053 suite does — the two carry the SAME option set, so any + * difference between the two rendered cells is the widget's own doing. */ import { describe, it, expect, vi, afterEach } from 'vitest'; @@ -97,8 +123,8 @@ const firstRowCells = (): string[] => (td) => td.textContent ?? '', ); -describe('DatasetWidget dotted-dimension labels on table / pivot (objectui#4263)', () => { - it('resolves a DOTTED dimension on a table, and leaves the LOCAL one exactly as it arrived', async () => { +describe('DatasetWidget dotted-dimension labels on table / pivot (objectui#4263, amended by #4330)', () => { + it('resolves a DOTTED dimension on a table, and the LOCAL one alongside it (#4330)', async () => { // Both dimensions carry the SAME option set. The server sent both // value-keyed — for the local one that is the server's own business // (ADR-0021 resolution is its job and this fixture models a server that @@ -125,26 +151,41 @@ describe('DatasetWidget dotted-dimension labels on table / pivot (objectui#4263) />, ); - // THE GAP: the dotted dimension resolves to its option label. Pre-fix this - // cell held the raw stored `education`. + // THE GAP: the dotted dimension resolves to its option label. Pre-#4263 + // this cell held the raw stored `education`. await waitFor(() => expect(firstRowCells()[1]).toBe('Education')); - // THE BOUNDARY: the local dimension is untouched in the same render. The - // server owns that label on a table; resolving it here would be a second - // resolution of the same value. - expect(firstRowCells()[0]).toBe('education'); + // AMENDED BY #4330 — the local dimension resolves in the same render. + // Under #4263 this asserted `education`: the client net was OFF for a local + // dimension because the server owns that label on a table. The read is now + // issued for every dimension (the locale bundle needs the option list), and + // resolving a value the server left raw is the same map doing the same + // thing — value-keyed, idempotent, so a label the server DID resolve is + // still not touched twice (pinned under `en` in + // `DatasetWidget.localSelectI18n.test.tsx`). + expect(firstRowCells()[0]).toBe('Education'); // It got there through the same `GET /meta/object/:name` channel #4261 // already uses, walking to the relationship target. expect(requested).toEqual(['crm_opportunity', 'crm_account']); }); - it('renders a LOCAL-only table exactly as the server sent it, resolving nothing', async () => { - // Control for the acceptance boundary: a table whose dimensions are all - // local must render byte-identically to today. The server-resolved label - // passes through untouched AND no object metadata is fetched at all — the - // early return still holds for this widget, so there is no resolution that - // could double-apply. + it('renders a LOCAL-only table exactly as the server sent it, and issues the ONE read (#4330)', async () => { + // ⚠️ THE AMENDED PIN (objectui#4330). Under #4263 this asserted + // `expect(requested).toEqual([])` — a local-only table issued NO metadata + // read at all, which was the acceptance boundary for dotted-dimension + // label resolution. + // + // The boundary now reads: the read IS issued (it is what gives the locale + // bundle an option list to translate against — see the file header), and + // what stays untouched is the RENDERED STRING. With no bundle mounted the + // display equals the authored label, so `buildDimensionLabelMap` emits no + // key, `relabelDimensions` returns the server's rows BY IDENTITY, and the + // cell is byte-identical to #4263's. That identity — not the absence of a + // fetch — is what "no double resolution" means after the amendment. + // + // Exactly ONE read: `resolveDimensionFieldMeta` memoizes per call, and a + // local path never walks a relationship. const { requested } = installMetaRouter({ crm_opportunity: OPPORTUNITY, crm_account: ACCOUNT }); render( expect(firstRowCells()[0]).toBe('Education')); - expect(requested).toEqual([]); + expect(requested).toEqual(['crm_opportunity']); }); it('resolves BOTH the row and the column dimension of a dotted pivot, keeping server totals aligned', async () => { @@ -269,10 +310,11 @@ describe('DatasetWidget dotted-dimension labels on table / pivot (objectui#4263) expect(requested).toEqual(['crm_opportunity', 'crm_account', 'crm_user']); }); - it('exports the resolved label for a dotted column and the untouched value for a local one', async () => { - // The CSV is the table's data, so it follows the table's cells: a dotted - // dimension exports what the table now shows. For a LOCAL dimension the - // export is unchanged for the same reason the cell is. + it('exports the resolved label for BOTH the dotted and the local column (#4330)', async () => { + // The CSV is the table's data, so it follows the table's cells — unchanged + // as a rule, and the cells it follows are the amended ones. Under #4263 + // this expected `education,Education,10`; the local column now resolves for + // the same reason its cell does. Measures stay numeric either way. const origCreate = URL.createObjectURL; const origRevoke = URL.revokeObjectURL; const blobs: any[] = []; @@ -307,7 +349,7 @@ describe('DatasetWidget dotted-dimension labels on table / pivot (objectui#4263) // downloadCsv prepends a UTF-8 BOM so Excel reads non-ASCII labels. const text: string = (await blobs[0].text()).replace(/^\uFEFF/, ''); const [, body] = text.split('\r\n'); - expect(body).toBe('education,Education,10'); + expect(body).toBe('Education,Education,10'); } finally { (URL as any).createObjectURL = origCreate; (URL as any).revokeObjectURL = origRevoke; diff --git a/packages/plugin-dashboard/src/__tests__/DatasetWidget.localSelectI18n.test.tsx b/packages/plugin-dashboard/src/__tests__/DatasetWidget.localSelectI18n.test.tsx new file mode 100644 index 000000000..02e6ecc01 --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/DatasetWidget.localSelectI18n.test.tsx @@ -0,0 +1,445 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#4330 — a LOCAL select dimension on a table / pivot dataset widget + * renders the object's authored ENGLISH option label while the related list on + * the same screen renders the zh-CN translation. The residual of objectui#4030 + * that PR #4324 measured and deliberately left open. + * + * ## Why #4324 could not reach it + * + * A local dimension arrives SERVER-RESOLVED (ADR-0021: the rows carry the + * object's authored label, not the stored value), and objectui#4263's landed + * acceptance pin asserted that this path issues **no metadata read at all**. + * The locale bundle is keyed by the option's stored value + * (`{ns}.fieldOptions...`), so translating needs the + * option LIST — i.e. exactly the read that pin forbade. There was nothing on + * the client to translate against, so the cells stayed English. + * + * ## The amended boundary (PM ruling recorded on #4330 at queue time) + * + * #4263's no-metadata-read pin was an acceptance boundary for DOTTED-dimension + * label RESOLUTION — "the label already exists, do not produce it twice" — + * ruled before the read had a second consumer. #4030 gave it one. The boundary + * is therefore amended deliberately, in the same PR that uses it: a table / + * pivot takes the ONE read needed to feed the SAME seam #4324 landed + * (`resolveDimensionFieldMeta` → `localizeFieldOptions` / + * `buildDimensionLabelMap` → `relabelDimensions`). One channel, no second + * dialect. The amended pins live in `DatasetWidget.dottedDimensionTable.test.tsx` + * (#4263's own file) and in `DatasetWidget.optionLabelI18n.test.tsx` (#4324's + * restatement of it); this file is the new behaviour. + * + * ## Why the read is NOT gated on "a select dimension is present" + * + * Measured, not assumed — select-ness is invisible before the read: + * + * - `@objectstack/spec`'s `DatasetDimension.type` is + * `string | number | date | boolean | lookup`. There is no `select` member, + * so no authored dataset can declare one; + * - every select dimension in the live example apps declares `type: 'string'` + * (`showcase_task.status` / `.priority`, `showcase_invoice.status`, + * `showcase_account.industry` / `.sales_region` — all `Field.select` objects + * behind a `type: 'string'` dimension); + * - on the wire, `AnalyticsResult.fields[]` types a select column `'string'` + * too (the analytics cube registry's `fieldTypeToDimensionType` maps + * everything that is not number/boolean/date to `'string'`). + * + * So the gate lands on the read's OUTPUT, where it is exact rather than + * heuristic: `resolveDimensionFieldMeta` yields an entry only for a terminal + * field that actually carries `options`. `pins the read is issued ONCE …` and + * `BOUNDARY — a dimension whose field carries no options …` below are that + * measurement, stated as tests. + * + * DIRECTIONS, written before the reverse verification was run: + * - the zh-CN cases (cells, pivot headers, pivot totals, CSV) are RED before + * the change — they render the authored English label, which IS the bug; + * - the drill pin is red too, for a SEQUENCING reason rather than an assertion + * one: it waits for the translated cell so the click lands after the + * metadata read settles, and that wait is what fails without the seam. Its + * assertion — the filter — is direction-independent; + * - the `en` case, the no-bundle-entry case and the no-options case are GREEN + * on both sides. They are the acceptance boundary: an untranslated app keeps + * exactly the label it renders today, and a dimension that owns no options + * is untouched. + * - the two read-count pins are red before the change for the direct reason: + * the read they count did not happen. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, cleanup, screen, waitFor, within, fireEvent } from '@testing-library/react'; +import { I18nProvider } from '@object-ui/i18n'; +import { DatasetWidget } from '../DatasetWidget'; + +/** 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(); + drillFilters.length = 0; + vi.restoreAllMocks(); +}); + +/** The card's fields, on the card's object — #4030's fixture verbatim. */ +const CHANNEL_OPTIONS = [ + { value: 'domestic', label: 'Domestic' }, + { value: 'export', label: 'Export' }, +]; +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 OPPORTUNITY = { + name: 'crm_opportunity', + fields: { + sales_channel: { type: 'select', options: CHANNEL_OPTIONS }, + competitor_name: { type: 'select', options: COMPETITOR_OPTIONS }, + // A dimension whose field carries no `options` at all — the read finds + // nothing for it and it renders exactly as the server sent it. + owner_note: { type: 'text' }, + }, +}; + +const ZH_BUNDLE = { + zh: { + crm: { + fields: { + crm_opportunity: { sales_channel: '销售渠道', competitor_name: '竞争对手' }, + }, + fieldOptions: { + crm_opportunity: { + sales_channel: { domestic: '国内', export: '出口' }, + competitor_name: { cabot: '卡博特', orion: '欧励隆' }, + }, + }, + }, + }, +}; + +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) }); + +function renderIn(language: string, ui: React.ReactElement) { + return render( + + {ui} + , + ); +} + +/** + * A flat table over TWO local select dimensions, as the server sends it: + * ADR-0021 already replaced the stored values with the object's AUTHORED + * labels, which is precisely why the screen reads English. + */ +const serverResolvedTable = () => + sourceOf({ + rows: [ + { sales_channel: 'Domestic', competitor_name: 'Orion Engineered Carbons', deals: 7 }, + { sales_channel: 'Export', competitor_name: 'Cabot', deals: 3 }, + ], + fields: [ + // `type: 'string'` deliberately — that is what a select dimension is on + // the wire. See the header: this is the reason the read is not gated on it. + { name: 'sales_channel', type: 'string', label: 'Sales Channel' }, + { name: 'competitor_name', type: 'string', label: 'Competitor' }, + { name: 'deals', type: 'number', label: 'Deals' }, + ], + object: 'crm_opportunity', + dimensionFields: { sales_channel: 'sales_channel', competitor_name: 'competitor_name' }, + drillRawRows: [ + { sales_channel: 'domestic', competitor_name: 'orion' }, + { sales_channel: 'export', competitor_name: 'cabot' }, + ], + }); + +/** Cell texts of the flat table's rows, in column order. */ +const rowCells = (i: number): string[] => + Array.from(document.querySelectorAll('tbody tr')[i]?.querySelectorAll('td') ?? []).map( + (td) => td.textContent ?? '', + ); + +describe('DatasetWidget table — a LOCAL select dimension is localized (objectui#4330)', () => { + it('renders the zh-CN option labels in the CELLS of a table', async () => { + installMetaRouter({ crm_opportunity: OPPORTUNITY }); + renderIn( + 'zh', + , + ); + + await waitFor(() => expect(rowCells(0)[0]).toBe('国内')); + expect(rowCells(0)[1]).toBe('欧励隆'); + expect(rowCells(1)[0]).toBe('出口'); + expect(rowCells(1)[1]).toBe('卡博特'); + // The authored English label must not survive beside the translation. + expect(document.body.textContent).not.toContain('Orion Engineered Carbons'); + }); + + it('pins the read is issued ONCE for a table, whatever its dimension count', async () => { + // The gating measurement (see the file header). Two dimensions on one + // object cost ONE `GET /meta/object/:name`: `resolveDimensionFieldMeta` + // memoizes per call, so sibling dimensions share the base schema fetch. + const { requested } = installMetaRouter({ crm_opportunity: OPPORTUNITY }); + renderIn( + 'zh', + , + ); + await waitFor(() => expect(rowCells(0)[0]).toBe('国内')); + expect(requested).toEqual(['crm_opportunity']); + }); + + it('BOUNDARY — under `en` the table reads exactly what it reads today', async () => { + // Green on both sides, with the same bundle mounted: an `en` console keeps + // the object's authored labels. `localizeFieldOptions` / + // `buildDimensionLabelMap` emit no key when the display equals the authored + // label, so `relabelDimensions` hands back the server's rows by identity. + installMetaRouter({ crm_opportunity: OPPORTUNITY }); + renderIn( + 'en', + , + ); + + await waitFor(() => expect(rowCells(0)[1]).toBe('Orion Engineered Carbons')); + expect(rowCells(0)[0]).toBe('Domestic'); + expect(document.body.textContent).not.toContain('国内'); + }); + + it('BOUNDARY — an option the bundle does not carry keeps its authored label', async () => { + // Green on both sides. Not sequenced on a translated string, so it survives + // the reverse verification: `Birla Carbon` is what this cell reads with the + // seam and without it. + installMetaRouter({ crm_opportunity: OPPORTUNITY }); + renderIn( + 'zh', + , + ); + + await waitFor(() => expect(rowCells(0)[0]).toBe('Birla Carbon')); + }); + + it('BOUNDARY — a dimension whose field carries no options renders untouched', async () => { + // The accepted cost of not gating the read on a client-visible "select" + // signal (there is none — see the header): the read IS issued, and it + // resolves nothing, so the rows come back by identity. This is the pin that + // makes "the select gate lands on the read's output" literal. + const { requested } = installMetaRouter({ crm_opportunity: OPPORTUNITY }); + renderIn( + 'zh', + , + ); + + await waitFor(() => expect(rowCells(0)[0]).toBe('renewal risk')); + expect(requested).toEqual(['crm_opportunity']); + }); + + it('a drilled row still filters by the STORED value, AFTER the relabel', async () => { + // Display translates; identity keys do not (objectui#4263 / #4273). + // + // Sequenced on the translated cell, which puts this pin in the RED set for + // a SEQUENCING reason rather than an assertion one: clicking as soon as the + // rows exist races the metadata read, and a drill asserted before the + // relabel lands proves nothing about a drill after it. This fixture arrives + // server-resolved, so the translation is the only observable "the read + // settled" signal. The assertion itself is direction-independent — the + // filter reads `drillRawRows`, which no relabel touches. (#4324's chart + // drill pin, untouched, still holds the same property green in both + // directions.) + installMetaRouter({ crm_opportunity: OPPORTUNITY }); + renderIn( + 'zh', + , + ); + + await waitFor(() => expect(rowCells(0)[0]).toBe('国内')); + const row = (await screen.findAllByTestId('dataset-drill-row'))[0]; + fireEvent.click(row); + await waitFor(() => expect(drillFilters.length).toBeGreaterThan(0)); + expect(drillFilters[drillFilters.length - 1]).toMatchObject({ + sales_channel: 'domestic', + competitor_name: 'orion', + }); + }); + + it('exports the localized dimension cell and the RAW numeric measure', async () => { + // The CSV follows the table's own cells (objectui#4263's convention, which + // #4324's own summary restates) — so a localized cell exports localized, + // and the UTF-8 BOM `downloadCsv` prepends is what makes Excel read it. The + // measure stays a number, because a CSV is data that must round-trip. + const origCreate = URL.createObjectURL; + const origRevoke = URL.revokeObjectURL; + const blobs: any[] = []; + (URL as any).createObjectURL = (b: any) => { blobs.push(b); return 'blob:x'; }; + (URL as any).revokeObjectURL = () => {}; + try { + installMetaRouter({ crm_opportunity: OPPORTUNITY }); + renderIn( + 'zh', + , + ); + await waitFor(() => expect(rowCells(0)[0]).toBe('国内')); + fireEvent.click(await screen.findByTestId('dataset-export')); + expect(blobs).toHaveLength(1); + const text: string = (await blobs[0].text()).replace(/^\uFEFF/, ''); + const [, body] = text.split('\r\n'); + expect(body).toBe('国内,欧励隆,7'); + } finally { + (URL as any).createObjectURL = origCreate; + (URL as any).revokeObjectURL = origRevoke; + } + }); +}); + +describe('DatasetWidget pivot — headers AND server totals localize together (objectui#4330)', () => { + const pivotSource = () => + sourceOf({ + rows: [ + { sales_channel: 'Domestic', competitor_name: 'Orion Engineered Carbons', deals: 5 }, + { sales_channel: 'Export', competitor_name: 'Cabot', deals: 7 }, + ], + fields: [ + { name: 'sales_channel', type: 'string', label: 'Sales Channel' }, + { name: 'competitor_name', type: 'string', label: 'Competitor' }, + { name: 'deals', type: 'number', label: 'Deals' }, + ], + object: 'crm_opportunity', + dimensionFields: { sales_channel: 'sales_channel', competitor_name: 'competitor_name' }, + // The server resolves its marginal totals through the SAME ADR-0021 pass + // it resolves the rows with, so they arrive carrying authored labels too. + totals: [ + { + dimensions: ['sales_channel'], + rows: [ + { sales_channel: 'Domestic', deals: 5 }, + { sales_channel: 'Export', deals: 7 }, + ], + }, + { + dimensions: ['competitor_name'], + rows: [ + { competitor_name: 'Orion Engineered Carbons', deals: 5 }, + { competitor_name: 'Cabot', deals: 7 }, + ], + }, + { dimensions: [], rows: [{ deals: 12 }] }, + ], + }); + + it('localizes the DOWN axis, the ACROSS headers, and keeps the totals aligned', async () => { + // The bucket ids the totals lookup uses are derived from the same values + // the headers display, so relabeling one side only would silently break the + // other — the headers would read 国内 while every total cell fell back to + // `—`. The totals are what pin that. + installMetaRouter({ crm_opportunity: OPPORTUNITY }); + renderIn( + 'zh', + , + ); + + const matrix = await screen.findByTestId('dataset-matrix'); + await waitFor(() => { + const down = Array.from(matrix.querySelectorAll('tbody tr td')).map((td) => td.textContent); + expect(down).toContain('国内'); + expect(down).toContain('出口'); + }); + const headers = Array.from(matrix.querySelectorAll('thead th')).map((th) => th.textContent); + expect(headers).toContain('欧励隆'); + expect(headers).toContain('卡博特'); + expect(headers).not.toContain('Orion Engineered Carbons'); + + // …and the server's marginal totals still find their buckets after it. + const rowTotals = within(matrix).getAllByTestId('matrix-row-total').map((td) => td.textContent); + expect(rowTotals).toEqual(['5', '7']); + expect(within(matrix).getByTestId('matrix-grand-total').textContent).toBe('12'); + }); +}); diff --git a/packages/plugin-dashboard/src/__tests__/DatasetWidget.optionLabelI18n.test.tsx b/packages/plugin-dashboard/src/__tests__/DatasetWidget.optionLabelI18n.test.tsx index 167b263ed..42cded474 100644 --- a/packages/plugin-dashboard/src/__tests__/DatasetWidget.optionLabelI18n.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/DatasetWidget.optionLabelI18n.test.tsx @@ -37,6 +37,14 @@ * "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). + * + * ⚠️ AMENDED BY objectui#4330. The last pin in this file was #4263's + * no-metadata-read boundary restated under a mounted bundle, and it was the + * measured record of what #4324 did NOT close: a LOCAL select on a table + * rendered `Orion Engineered Carbons` under `zh` because the client had no + * option list to translate against. The PM amended that boundary deliberately + * (see the pin's own comment); the widened behaviour has its own suite in + * `DatasetWidget.localSelectI18n.test.tsx`. Everything else here is untouched. */ import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest'; @@ -323,12 +331,18 @@ describe('DatasetWidget table/pivot — the dotted gap-fill translates too (obje 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. + it('AMENDED (#4330) — a LOCAL select on a table now reads the bundle too', async () => { + // ⚠️ This pin is the one objectui#4330 amended, and it is where #4324's + // measured gap was recorded. It used to read "a LOCAL-only table still + // resolves nothing and fetches nothing": `expect(requested).toEqual([])` + // and the cell asserted as `Orion Engineered Carbons` UNDER A MOUNTED zh + // BUNDLE — i.e. this test's own green was the bug's pin. #4324's summary + // said so out loud ("what this does NOT close"): the server resolves that + // label in English, the bundle is keyed by the stored VALUE, and #4263's + // boundary forbade the one read that could connect the two. + // + // Amended by PM ruling on #4330: the read is issued, the same seam applies, + // and the cell reads the translation the related list beside it reads. const { requested } = installMetaRouter({ crm_opportunity: OPPORTUNITY, crm_account: ACCOUNT }); renderIn( 'zh', @@ -345,7 +359,10 @@ describe('DatasetWidget table/pivot — the dotted gap-fill translates too (obje })} />, ); - await waitFor(() => expect(screen.getByText('Orion Engineered Carbons')).toBeTruthy()); - expect(requested).toEqual([]); + await waitFor(() => expect(screen.getByText('欧励隆')).toBeTruthy()); + expect(screen.queryByText('Orion Engineered Carbons')).toBeNull(); + // ONE read, on the dataset's base object — a local path walks no + // relationship, so `crm_account` is never fetched. + expect(requested).toEqual(['crm_opportunity']); }); }); diff --git a/packages/plugin-report/src/DatasetReportRenderer.tsx b/packages/plugin-report/src/DatasetReportRenderer.tsx index 5180ccb97..bc118c288 100644 --- a/packages/plugin-report/src/DatasetReportRenderer.tsx +++ b/packages/plugin-report/src/DatasetReportRenderer.tsx @@ -64,6 +64,7 @@ import { formatDimensionValue, buildDatasetFieldHelpers, buildDatasetDrillFilter, + relabelDimensions, pivotBucketId, pivotDimensionValue, pivotCellKey, @@ -72,6 +73,7 @@ import { } from '@object-ui/core'; import { useSafeFieldLabel, useSafeTranslate } from '@object-ui/i18n'; import { mergeFilters } from './mergeFilters'; +import { useDatasetDimensionLabels } from './useDatasetDimensionLabels'; type Row = Record; @@ -409,6 +411,10 @@ function DatasetReportTable({ ); const { fieldLabel } = useSafeFieldLabel(); const tt = useSafeTranslate(); + // objectui#4330 — the option list this report's select dimensions are + // localized against. Null (and free) for a report whose dimensions own no + // options, or before the read lands. + const dimensionLabels = useDatasetDimensionLabels(state.object, state.dimensionFields, rows); if (values.length === 0) return ; if (state.status === 'loading' || state.status === 'idle') return ; @@ -440,6 +446,11 @@ function DatasetReportTable({ const { measureField, headerLabel } = buildDatasetFieldHelpers(state.fields, state.object, fieldLabel); const columns = [...rows, ...values]; + // The rows as DISPLAYED (objectui#4330). Order and count are preserved, so + // `state.rows[i]` / `drillRawRows[i]` stay index-aligned — which is what + // keeps the drill payload below on the untranslated values. With no label + // map this is `state.rows` ITSELF, the same array identity as before. + const displayRows = relabelDimensions(state.rows, dimensionLabels); // Server-supplied grand total (`dimensions: []`); absent → no totals row. const grandTotal = withTotals ? state.totals?.find((t) => Array.isArray(t.dimensions) && t.dimensions.length === 0)?.rows?.[0] @@ -458,12 +469,17 @@ function DatasetReportTable({ - {state.rows.map((row, i) => ( + {displayRows.map((row, i) => ( drill(row, i) : undefined} + // The drill payload is built from the row AS THE SERVER SENT IT + // (`state.rows[i]`), never from the displayed one: `groupKey` and + // `objectFilter` are identity keys the host filters records with, + // and translating those would address nothing (objectui#4263 / + // #4273's convention, restated for #4330). + onClick={canDrill ? () => drill(state.rows[i], i) : undefined} > {columns.map((c) => ( @@ -668,6 +684,16 @@ function DatasetReportChart({ ); const ChartComponent = useRegistryComponent('chart'); const { fieldLabel } = useSafeFieldLabel(); + // objectui#4330 — the embedded chart plots the SAME dimension the table + // beneath it groups by, so it takes the same label map. Leaving it out would + // put the two spellings of one value on one screen, which is the defect this + // family exists to close. + const chartDimensions = React.useMemo(() => (xAxis ? [xAxis] : []), [xAxis]); + const dimensionLabels = useDatasetDimensionLabels( + state.object, + state.dimensionFields, + chartDimensions, + ); const title = typeof chart.title === 'string' ? chart.title : undefined; @@ -727,7 +753,7 @@ function DatasetReportChart({ { if (state.status !== 'ok') return null; - const rowHeaders: Array<{ id: string; label: string; key: Row }> = []; - const colHeaders: Array<{ id: string; label: string; key: Row }> = []; + // `key` is the RAW bucket (drill identity); `display` is the same bucket as + // it reads on screen, per dimension — the two are deliberately separate + // (objectui#4330). + const rowHeaders: Array<{ id: string; label: string; key: Row; display: Row }> = []; + const colHeaders: Array<{ id: string; label: string; key: Row; display: Row }> = []; const seenRow = new Set(); const seenCol = new Set(); const cells = new Map(); - state.rows.forEach((r, index) => { + // Bucket ids and header labels come from the DISPLAY rows so both axes read + // the localized label; the `key` a drill click carries comes from the RAW + // row beside it, because that one addresses records (objectui#4263/#4273). + // Index alignment across the two arrays is guaranteed by + // `relabelDimensions`, which preserves order and count. + const displayRows = relabelDimensions(state.rows, dimensionLabels); + displayRows.forEach((r, index) => { + const raw = state.rows[index]; const rid = bucketId(rows, r); const cid = bucketId(columnsAcross, r); if (!seenRow.has(rid)) { seenRow.add(rid); const key: Row = {}; - for (const d of rows) key[d] = r[d]; - rowHeaders.push({ id: rid, label: bucketLabel(rows, r), key }); + const display: Row = {}; + for (const d of rows) { key[d] = raw[d]; display[d] = r[d]; } + rowHeaders.push({ id: rid, label: bucketLabel(rows, r), key, display }); } if (!seenCol.has(cid)) { seenCol.add(cid); const key: Row = {}; - for (const d of columnsAcross) key[d] = r[d]; - colHeaders.push({ id: cid, label: bucketLabel(columnsAcross, r), key }); + const display: Row = {}; + for (const d of columnsAcross) { key[d] = raw[d]; display[d] = r[d]; } + colHeaders.push({ id: cid, label: bucketLabel(columnsAcross, r), key, display }); } // Keyed by pivotCellKey, not `${rid} ${cid}`: a plain space is a boundary // only while no dimension value contains one, and they do constantly // ("New York", "In Progress"). `index` is also what drill-through reads // `drillRawRows` by, so a merged key drilled to the wrong records too. - cells.set(pivotCellKey(rid, cid), { row: r, index }); + // The cell holds MEASURES, which no relabel touches — so it keeps the + // server's own row. + cells.set(pivotCellKey(rid, cid), { row: raw, index }); }); return { rowHeaders, colHeaders, cells }; - }, [state, rows, columnsAcross]); + }, [state, rows, columnsAcross, dimensionLabels]); if (values.length === 0) return ; if (state.status === 'loading' || state.status === 'idle') return ; @@ -880,8 +925,18 @@ function DatasetMatrixTable({ // Server-supplied totals: match each grouping by its `dimensions` array, // then match its rows to the pivot headers via the same bucketId. Absent // (older server) → every map stays empty and no totals UI renders. - const findTotals = (dims: string[]) => - state.totals?.find((t) => Array.isArray(t.dimensions) && t.dimensions.join(',') === dims.join(','))?.rows; + // + // The totals carry dimension values too, so they take the SAME relabel the + // display rows took (objectui#4330 — the dashboard pivot does this for the + // same reason, #4263): both sides of the lookup must speak one vocabulary, + // or a zh-CN matrix would read 国内 down the side while the row-total lookup + // still asked for `Domestic` and every total cell fell back to blank. + const findTotals = (dims: string[]) => { + const totalRows = state.totals?.find( + (t) => Array.isArray(t.dimensions) && t.dimensions.join(',') === dims.join(','), + )?.rows; + return totalRows ? relabelDimensions(totalRows, dimensionLabels) : totalRows; + }; const rowTotalById = new Map(); for (const r of findTotals(rows) ?? []) rowTotalById.set(bucketId(rows, r), r); const colTotalById = new Map(); @@ -922,7 +977,7 @@ function DatasetMatrixTable({ {rows.map((d) => ( - {formatDimensionValue(rh.key[d])} + {formatDimensionValue(rh.display[d])} ))} {cellCols.map((cc) => { diff --git a/packages/plugin-report/src/__tests__/DatasetReportRenderer.localSelectI18n.test.tsx b/packages/plugin-report/src/__tests__/DatasetReportRenderer.localSelectI18n.test.tsx new file mode 100644 index 000000000..e70f29349 --- /dev/null +++ b/packages/plugin-report/src/__tests__/DatasetReportRenderer.localSelectI18n.test.tsx @@ -0,0 +1,341 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#4330, the report half — a dataset-bound REPORT renders a local + * select dimension's authored ENGLISH option label under a zh-CN console. + * + * `DatasetReportRenderer` is the same shape as the dashboard's table/pivot + * widget and for the same reason: it renders SERVER-RESOLVED rows (ADR-0021) + * and, before this change, loaded no object schema at all. PR #4324 therefore + * carried no change to this package — it measured the gap and wrote it up as + * "what this does NOT close". With no option list on the client there is + * nothing for the locale bundle (keyed by the option's stored VALUE) to + * translate against. + * + * The fix is the SAME channel the dashboard uses, reached through + * `useDatasetDimensionLabels` (`resolveDimensionFieldMeta` → + * `buildDimensionLabelMap` → `relabelDimensions`, translator + * `useSafeFieldLabel().fieldOptionLabel`). No report-side resolution dialect. + * + * DIRECTIONS, written before the reverse verification was run: + * - the zh-CN cases (tabular cells, matrix down-axis, matrix across headers, + * matrix totals, the embedded chart's categories) are RED before the change; + * - the two drill pins are red too, for a SEQUENCING reason rather than an + * assertion one: they wait for the translated cell so the click lands after + * the metadata read settles, and that wait is what fails without the seam. + * Their assertion — `groupKey` / `objectFilter` carrying the values the + * SERVER sent (objectui#4263 / #4273's convention) — is direction- + * independent, and clicking before the relabel would have proved nothing + * about a drill after it; + * - the `en` case is GREEN on both sides: a console with no translation keeps + * the exact label it renders today. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, cleanup, screen, waitFor, within, fireEvent } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { I18nProvider } from '@object-ui/i18n'; +import { DatasetReportRenderer, type DatasetDrillArgs } from '../DatasetReportRenderer'; + +const CHANNEL_OPTIONS = [ + { value: 'domestic', label: 'Domestic' }, + { value: 'export', label: 'Export' }, +]; +const COMPETITOR_OPTIONS = [ + { value: 'cabot', label: 'Cabot' }, + { value: 'orion', label: 'Orion Engineered Carbons' }, +]; + +const OPPORTUNITY = { + name: 'crm_opportunity', + fields: { + sales_channel: { type: 'select', options: CHANNEL_OPTIONS }, + competitor_name: { type: 'select', options: COMPETITOR_OPTIONS }, + }, +}; + +const ZH_BUNDLE = { + zh: { + crm: { + fields: { crm_opportunity: { sales_channel: '销售渠道', competitor_name: '竞争对手' } }, + fieldOptions: { + crm_opportunity: { + sales_channel: { domestic: '国内', export: '出口' }, + competitor_name: { cabot: '卡博特', orion: '欧励隆' }, + }, + }, + }, + }, +}; + +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) }); + +function renderIn(language: string, ui: React.ReactElement) { + return render( + + {ui} + , + ); +} + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +/** One grouped row per channel, server-resolved to the AUTHORED label. */ +const tabularResult = { + rows: [ + { sales_channel: 'Domestic', deals: 7 }, + { sales_channel: 'Export', deals: 3 }, + ], + fields: [ + { name: 'sales_channel', type: 'string', label: 'Sales Channel' }, + { name: 'deals', type: 'number', label: 'Deals' }, + ], + object: 'crm_opportunity', + dimensionFields: { sales_channel: 'sales_channel' }, + drillRawRows: [{ sales_channel: 'domestic' }, { sales_channel: 'export' }], +}; + +const cellsOf = (row: Element) => Array.from(row.querySelectorAll('td')).map((td) => td.textContent); + +describe('DatasetReportRenderer tabular — local select dimensions localize (objectui#4330)', () => { + it('renders the zh-CN option label in the report cells', async () => { + installMetaRouter({ crm_opportunity: OPPORTUNITY }); + renderIn( + 'zh', + , + ); + + await waitFor(() => expect(screen.getByText('国内')).toBeTruthy()); + expect(screen.getByText('出口')).toBeTruthy(); + expect(document.body.textContent).not.toContain('Domestic'); + }); + + it('pins the read: ONE metadata fetch, on the dataset base object', async () => { + const { requested } = installMetaRouter({ crm_opportunity: OPPORTUNITY }); + renderIn( + 'zh', + , + ); + await waitFor(() => expect(screen.getByText('国内')).toBeTruthy()); + expect(requested).toEqual(['crm_opportunity']); + }); + + it('BOUNDARY — under `en` the report reads exactly what it reads today', async () => { + installMetaRouter({ crm_opportunity: OPPORTUNITY }); + renderIn( + 'en', + , + ); + + await waitFor(() => expect(screen.getByText('Domestic')).toBeTruthy()); + expect(document.body.textContent).not.toContain('国内'); + }); + + it('a drilled row carries the values the SERVER sent, AFTER the relabel', async () => { + // `groupKey` and `objectFilter` are what the host filters records with; the + // display translating must not move them. + // + // Sequenced on the translated cell deliberately, which is why this pin is + // in the RED set rather than a both-directions boundary: clicking as soon + // as the rows exist races the metadata read, and a drill asserted before + // the relabel lands proves nothing about a drill after it. There is no + // direction-independent "the read settled" signal on this fixture — the + // rows arrive server-resolved, so the translation IS the signal. The + // ASSERTION below is direction-independent; only the sequencing is not. + installMetaRouter({ crm_opportunity: OPPORTUNITY }); + const drills: DatasetDrillArgs[] = []; + renderIn( + 'zh', + drills.push(a)} + />, + ); + + await screen.findByText('国内'); + const row = (await screen.findAllByTestId('dataset-drill-row'))[0]; + expect(cellsOf(row)[0]).toBe('国内'); + fireEvent.click(row); + await waitFor(() => expect(drills.length).toBe(1)); + // The server-sent bucket value, never the localized one. + expect(drills[0].groupKey).toEqual({ sales_channel: 'Domestic' }); + // …and the exact record filter still uses the RAW stored value. + expect(drills[0].objectFilter).toMatchObject({ sales_channel: 'domestic' }); + }); +}); + +describe('DatasetReportRenderer matrix — both axes and the totals (objectui#4330)', () => { + const matrixResult = { + rows: [ + { sales_channel: 'Domestic', competitor_name: 'Orion Engineered Carbons', deals: 5 }, + { sales_channel: 'Export', competitor_name: 'Cabot', deals: 7 }, + ], + fields: [ + { name: 'sales_channel', type: 'string', label: 'Sales Channel' }, + { name: 'competitor_name', type: 'string', label: 'Competitor' }, + { name: 'deals', type: 'number', label: 'Deals' }, + ], + object: 'crm_opportunity', + dimensionFields: { sales_channel: 'sales_channel', competitor_name: 'competitor_name' }, + drillRawRows: [ + { sales_channel: 'domestic', competitor_name: 'orion' }, + { sales_channel: 'export', competitor_name: 'cabot' }, + ], + // Server-resolved on the same ADR-0021 pass as the rows. + totals: [ + { + dimensions: ['sales_channel'], + rows: [ + { sales_channel: 'Domestic', deals: 5 }, + { sales_channel: 'Export', deals: 7 }, + ], + }, + { + dimensions: ['competitor_name'], + rows: [ + { competitor_name: 'Orion Engineered Carbons', deals: 5 }, + { competitor_name: 'Cabot', deals: 7 }, + ], + }, + { dimensions: [], rows: [{ deals: 12 }] }, + ], + }; + + it('localizes the down axis and the across headers, keeping server totals aligned', async () => { + // The subtotal lookup is keyed by the same bucket ids the headers are built + // from, so relabeling one side only would blank every total cell. That is + // what the totals assertions pin. + installMetaRouter({ crm_opportunity: OPPORTUNITY }); + renderIn( + 'zh', + , + ); + + const matrix = await screen.findByTestId('dataset-matrix'); + await waitFor(() => { + const down = Array.from(matrix.querySelectorAll('tbody tr td')).map((td) => td.textContent); + expect(down).toContain('国内'); + expect(down).toContain('出口'); + }); + const headers = Array.from(matrix.querySelectorAll('thead th')).map((th) => th.textContent); + expect(headers).toContain('欧励隆'); + expect(headers).toContain('卡博特'); + expect(headers).not.toContain('Orion Engineered Carbons'); + + const rowTotals = within(matrix).getAllByTestId('matrix-row-total').map((td) => td.textContent); + expect(rowTotals).toEqual(['5', '7']); + expect(within(matrix).getByTestId('matrix-grand-total').textContent).toBe('12'); + }); + + it('a drilled CELL carries the values the SERVER sent, AFTER the relabel', async () => { + // Same shape as the tabular pin above, and in the RED set for the same + // sequencing reason. The matrix is where it matters most: the header a user + // clicks under is built from the DISPLAY row while `key` comes from the RAW + // one, and those are two different objects in this renderer. + installMetaRouter({ crm_opportunity: OPPORTUNITY }); + const drills: DatasetDrillArgs[] = []; + renderIn( + 'zh', + drills.push(a)} + />, + ); + + // Wait for the relabel to land before clicking (see the pin above). + await screen.findByText('国内'); + const cell = (await screen.findAllByTestId('dataset-drill-cell'))[0]; + // Guard on the click landing on the row it looks like it lands on. + expect(cellsOf(cell.closest('tr')!)[0]).toBe('国内'); + fireEvent.click(cell); + await waitFor(() => expect(drills.length).toBe(1)); + expect(drills[0].groupKey).toEqual({ + sales_channel: 'Domestic', + competitor_name: 'Orion Engineered Carbons', + }); + expect(drills[0].objectFilter).toMatchObject({ + sales_channel: 'domestic', + competitor_name: 'orion', + }); + }); +}); + +describe("DatasetReportRenderer embedded chart — one screen, one spelling (objectui#4330)", () => { + it('plots the localized category, matching the table beneath it', async () => { + // The report's chart groups by the SAME dimension the table does. Leaving + // it out would put 国内 in the table and `Domestic` on the axis of the very + // same report. + let captured: any = null; + ComponentRegistry.register('chart', (props: any) => { + captured = props; + return null; + }); + installMetaRouter({ crm_opportunity: OPPORTUNITY }); + renderIn( + 'zh', + , + ); + + await waitFor(() => { + const categories = (captured?.schema?.data ?? []).map((r: any) => r.sales_channel); + expect(categories).toContain('国内'); + }); + const categories = (captured.schema.data ?? []).map((r: any) => r.sales_channel); + expect(categories).not.toContain('Domestic'); + }); +}); diff --git a/packages/plugin-report/src/useDatasetDimensionLabels.ts b/packages/plugin-report/src/useDatasetDimensionLabels.ts new file mode 100644 index 000000000..6fc43ab02 --- /dev/null +++ b/packages/plugin-report/src/useDatasetDimensionLabels.ts @@ -0,0 +1,159 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * useDatasetDimensionLabels — the analytics label net for dataset-bound + * REPORTS (objectui#4330). + * + * ## What this is + * + * The `{dimension → {value|authoredLabel → displayLabel}}` map every surface in + * `DatasetReportRenderer` relabels its rows through. It is the SAME channel the + * dashboard's `DatasetWidget` uses, not a report-side dialect — the resolution + * walk, the option localization and the map construction are all the shared + * `@object-ui/core` helpers PR #4324 landed: + * + * `resolveDimensionFieldMeta` → `localizeFieldOptions` / `buildDimensionLabelMap` + * → `relabelDimensions` (applied by the caller) + * + * and the translator is `useSafeFieldLabel().fieldOptionLabel`, i.e. + * `{ns}.fieldOptions...` — the one convention list and + * form surfaces already translate select options through. Nothing here decides + * what a label IS; it only carries the object metadata to the helpers that do. + * + * ## Why a report-local hook rather than a shared one + * + * The natural home for this glue is `@object-ui/core`, beside the helpers it + * calls. It is here instead because `packages/core` is held by another task in + * flight (objectui#4040 tranche 5) and this card's surface is the two plugin + * packages. The DUPLICATION is the fetch-and-memo wiring only — roughly the + * shape `DatasetWidget`'s effect has — and never the resolution rules. Lifting + * it into core once tranche 5 lands is filed as a follow-up. + * + * ## Why the read is issued at all (the #4263 boundary, amended by #4330) + * + * A dataset report renders SERVER-RESOLVED rows (ADR-0021: a local select + * dimension arrives carrying the object's AUTHORED English option label) and + * used to load no object metadata whatever, which is exactly why PR #4324 + * carried no change to this package. With no option list on the client there is + * nothing for the locale bundle to translate against, so a zh-CN report read + * `Domestic` beside a related list reading 国内. The read is what closes that. + * + * It is not gated on "the dimension is a select", because select-ness is not + * observable before the read: the spec's `DatasetDimension.type` enum is + * `string|number|date|boolean|lookup` (no `select` member), and a select column + * arrives on the wire typed `'string'`. The gate lands on the read's OUTPUT + * instead, where it is exact — `resolveDimensionFieldMeta` returns an entry + * only for a terminal field that actually carries `options`, so a + * text/number/date/lookup dimension yields no map and `relabelDimensions` + * returns the caller's rows by identity. + */ + +import * as React from 'react'; +import { + buildDimensionLabelMap, + resolveDimensionFieldMeta, + type DimensionFieldMeta, + type OptionLabelTranslator, +} from '@object-ui/core'; +import { SchemaRendererContext } from '@object-ui/react'; +import { useSafeFieldLabel } from '@object-ui/i18n'; + +/** `{ dimension → { rowValue → displayLabel } }`, or null when nothing resolved. */ +export type DimensionLabelMaps = Record> | null; + +/** + * Resolve the label maps for one dataset query's dimensions. + * + * @param object the dataset's base object, as the query result reported it + * @param dimensionFields the result's `dimension → field path` map (a dotted + * path resolves against the relationship TARGET, ADR-0071 multi-hop included) + * @param dimensions the dimension names this surface renders + */ +export function useDatasetDimensionLabels( + object: string | undefined, + dimensionFields: Record | undefined, + dimensions: string[], +): DimensionLabelMaps { + const { fieldOptionLabel } = useSafeFieldLabel(); + // The host's AUTHENTICATED fetch (objectui#4121) — the same channel the + // dashboard widget's identical read rides, falling back to the global one + // when no host supplies it. Read directly off the context rather than through + // a `useSchemaContext()` that throws, so a report rendered outside a host + // (every existing suite in this package) keeps degrading instead of crashing. + const apiFetch = React.useContext(SchemaRendererContext)?.apiFetch; + + // Kept LOCALE-FREE in state, exactly as `DatasetWidget` keeps it (#4030): + // the bundle is applied in the memo below, so switching language re-labels in + // place instead of re-fetching the schema. + const [optionMeta, setOptionMeta] = React.useState<{ + metaByPath: Record; + relabel: Array<{ dim: string; path: string }>; + } | null>(null); + + // A string signature, for the same reason `useDatasetRows` uses one: `rows` / + // `columns` reach this renderer as arrays rebuilt on every render, so keying + // the effect on their identity would refetch forever. + const dims = dimensions.filter(Boolean); + const signature = `${object ?? ''}|${dims.join(',')}|${JSON.stringify(dimensionFields ?? null)}`; + + React.useEffect(() => { + if (!object || dims.length === 0) { + setOptionMeta(null); + return; + } + const fieldOf = (dim: string) => (dimensionFields && dimensionFields[dim]) || dim; + let cancelled = false; + (async () => { + try { + const doFetch = apiFetch ?? fetch; + const loadObjectSchema = async (name: string) => { + const r = await doFetch(`/api/v1/meta/object/${encodeURIComponent(name)}`, { + headers: { accept: 'application/json' }, + credentials: 'include', + }); + const doc = await r.json().catch(() => null); + return doc?.item ?? doc?.data ?? doc; + }; + const objSchema = await loadObjectSchema(object); + // ONE walk for every dimension, memoized per call — sibling dimensions + // sharing a relationship prefix fetch that object once. + const metaByPath = await resolveDimensionFieldMeta( + objSchema, + dims.map(fieldOf), + loadObjectSchema, + ); + if (!cancelled) { + setOptionMeta({ metaByPath, relabel: dims.map((dim) => ({ dim, path: fieldOf(dim) })) }); + } + } catch { + // Best-effort by construction: a failed read leaves the rows exactly as + // the server sent them, which is what this surface rendered before. + if (!cancelled) setOptionMeta(null); + } + })(); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [signature, apiFetch]); + + return React.useMemo(() => { + if (!optionMeta) return null; + const { metaByPath, relabel } = optionMeta; + // One translator per resolved path, bound to the object that OWNS the + // terminal field — the relationship TARGET for a dotted path, not the + // dataset's base object, because that is the object the bundle key names. + const translatorFor = (path: string): OptionLabelTranslator | undefined => { + const meta = metaByPath[path]; + const owner = meta?.object; + if (!owner) return undefined; + return (value, authored) => fieldOptionLabel(owner, meta.field, value, authored); + }; + const labels: Record> = {}; + for (const { dim, path } of relabel) { + const m = buildDimensionLabelMap(metaByPath[path]?.options, translatorFor(path)); + if (m) labels[dim] = m; + } + return Object.keys(labels).length > 0 ? labels : null; + }, [optionMeta, fieldOptionLabel]); +} From 7953b870e2fc3b5d35cec55008750b4a5d5f40c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 00:35:22 +0000 Subject: [PATCH 2/3] test(dashboard): correct the predicted direction of the no-options pin (#4330) The reverse verification measured that pin RED on its read-count assertion, not green in both directions as the header predicted: after this change the read IS issued for a dimension whose field owns no options, and resolves nothing. Its rendered half is green either way. Recording the measurement rather than the prediction. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 --- .../DatasetWidget.localSelectI18n.test.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/plugin-dashboard/src/__tests__/DatasetWidget.localSelectI18n.test.tsx b/packages/plugin-dashboard/src/__tests__/DatasetWidget.localSelectI18n.test.tsx index 02e6ecc01..dd266b1f1 100644 --- a/packages/plugin-dashboard/src/__tests__/DatasetWidget.localSelectI18n.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/DatasetWidget.localSelectI18n.test.tsx @@ -57,12 +57,17 @@ * one: it waits for the translated cell so the click lands after the * metadata read settles, and that wait is what fails without the seam. Its * assertion — the filter — is direction-independent; - * - the `en` case, the no-bundle-entry case and the no-options case are GREEN - * on both sides. They are the acceptance boundary: an untranslated app keeps - * exactly the label it renders today, and a dimension that owns no options - * is untouched. - * - the two read-count pins are red before the change for the direct reason: - * the read they count did not happen. + * - the `en` case and the no-bundle-entry case are GREEN on both sides. They + * are the acceptance boundary: an untranslated app keeps exactly the label + * it renders today; + * - every pin that counts the READ is red before the change for the direct + * reason — the read did not happen. That includes the no-options pin, whose + * RENDERED half is green in both directions (`renewal risk` either way) and + * whose read-count half is not. The prediction written here first said that + * pin was green on both sides; it was measured red on its second assertion + * and this line is the correction, not a re-run to fit the sentence. The + * distinction is the point of the pin: after this change the read is issued + * for a dimension that owns no options, and it resolves nothing. */ import { describe, it, expect, vi, afterEach } from 'vitest'; From f31a60c76ae590efa0955a536d0afe056c51822b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 00:52:56 +0000 Subject: [PATCH 3/3] docs(report): point the shared-glue note at the filed follow-up (#4389) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 --- packages/plugin-report/src/useDatasetDimensionLabels.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugin-report/src/useDatasetDimensionLabels.ts b/packages/plugin-report/src/useDatasetDimensionLabels.ts index 6fc43ab02..72086844d 100644 --- a/packages/plugin-report/src/useDatasetDimensionLabels.ts +++ b/packages/plugin-report/src/useDatasetDimensionLabels.ts @@ -27,7 +27,7 @@ * flight (objectui#4040 tranche 5) and this card's surface is the two plugin * packages. The DUPLICATION is the fetch-and-memo wiring only — roughly the * shape `DatasetWidget`'s effect has — and never the resolution rules. Lifting - * it into core once tranche 5 lands is filed as a follow-up. + * it into core once tranche 5 lands is filed as objectui#4389. * * ## Why the read is issued at all (the #4263 boundary, amended by #4330) *