diff --git a/.changeset/report-chart-measure-label-4020.md b/.changeset/report-chart-measure-label-4020.md new file mode 100644 index 0000000000..e403cbc559 --- /dev/null +++ b/.changeset/report-chart-measure-label-4020.md @@ -0,0 +1,21 @@ +--- +'@object-ui/plugin-report': patch +--- + +报表内嵌图表的度量显示名按三级回落解析,不再直接印原始 `name` + +数据集绑定的 report chart 此前把度量原样交给图表组件(`series: [{ dataKey }]`,无 label), +于是图例、标记 tooltip 与单值卡片的说明文字都落回 dataKey——在全中文控制台上打出 +`potential_upside_tons`,而同一张报表下方的汇总表、以及绑定同一数据集的 dashboard 图表 +都能正确解析出授权 `label`。`ReportChartSchema` 自 rc.1 起声明的 `series[].label` 也从未 +被读取,作者因此没有任何可授权的手段控制这个字符串。 + +现按三级回落解析,与汇总表和 dashboard 的既有口径对齐: + +1. `chart.series[]` 中 `name` 命中该度量的条目的 `label`(spec 的 `I18nLabel`,按控制台 + 语言解析;同名重复以第一条为准); +2. 绑定数据集的度量 `label`(结果字段的 `label`,即汇总表表头一直在读的同一个值); +3. 度量 `name` 兜底。 + +图例与 tooltip 同源同修:`ChartRenderer` 把 series 的 `label` 写进 `config[dataKey].label`, +三处读的是同一个输入。单值族(`metric`/`kpi`/`gauge`)的说明文字与系列图共用这一次解析。 diff --git a/packages/plugin-report/src/DatasetReportRenderer.tsx b/packages/plugin-report/src/DatasetReportRenderer.tsx index 99607f0829..ce548e0dfa 100644 --- a/packages/plugin-report/src/DatasetReportRenderer.tsx +++ b/packages/plugin-report/src/DatasetReportRenderer.tsx @@ -71,7 +71,7 @@ import { type DatasetResultField, type DatasetDrillRange, } from '@object-ui/core'; -import { useSafeFieldLabel, useSafeTranslate, useDisplayLocale } from '@object-ui/i18n'; +import { useSafeFieldLabel, useSafeTranslate, useDisplayLocale, useObjectTranslation, pickLocalized } from '@object-ui/i18n'; import { mergeFilters } from './mergeFilters'; import { useDatasetDimensionLabels } from './useDatasetDimensionLabels'; @@ -642,6 +642,37 @@ function useRegistryComponent( | undefined; } +/** + * The author's per-chart override for ONE measure's display name — the entry of + * `chart.series[]` whose `name` IS that measure (objectui#4020, level ①). + * + * `ReportChartSchema.series[]` has declared `{ name, label? }` since rc.1, but + * the renderer never read it: the chart was handed `series: [{ dataKey }]` with + * no label at all, so an authored `label` was inert metadata and the measure + * printed as its raw `name`. Declared is now enforced. + * + * The FIRST entry naming the measure wins, the same ruling the dashboard's + * `mergeAuthoredPresentation` makes for its own `chartConfig.series` — a later + * duplicate cannot silently re-label a series the author already described. + * + * `label` is the spec's `I18nLabel` (`string | { en, 'zh-CN', … }`), so it goes + * through `pickLocalized`, the repo's one resolver for that shape — a + * first-string-wins pick would put the English limb of a translated label on a + * zh-CN console, which is the very defect class this card closes. An entry that + * names the measure but carries no usable `label` returns `undefined` and falls + * through to the dataset's own measure label, exactly as an absent entry does. + */ +function authoredSeriesLabel(series: unknown, measure: string, language: string | undefined): string | undefined { + if (!Array.isArray(series) || !measure) return undefined; + for (const entry of series as unknown[]) { + if (!entry || typeof entry !== 'object') continue; + const { name, label } = entry as { name?: unknown; label?: unknown }; + if (name !== measure) continue; + return pickLocalized(label, language) || undefined; + } + return undefined; +} + /** * Render a report's embedded `chart` (ADR-0021) by running its OWN dataset * query — the `xAxis` dimension grouped, the `yAxis` measure aggregated — and @@ -696,6 +727,12 @@ function DatasetReportChart({ // and follows the display locale. (The series charts render their own labels // through the chart component, not through `formatMeasure`.) const displayLocale = useDisplayLocale(); + // objectui#4020 — the UI LANGUAGE an authored `series[].label` is picked in. + // Deliberately not `displayLocale`: that one prefers the tenant's REGIONAL + // locale (ADR-0053) because it feeds `Intl` number formatting, and a workspace + // formatting numbers as `de-DE` while its console runs zh must still read the + // zh limb of a translated label. + const { language } = useObjectTranslation(); // 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 @@ -738,8 +775,25 @@ function DatasetReportChart({ // On error or empty, fall back silently to the table beneath. if (state.status === 'error' || state.rows.length === 0) return null; + const { measureField, headerLabel } = buildDatasetFieldHelpers(state.fields, state.object, fieldLabel); + // The measure's display name, resolved ONCE for every branch below + // (objectui#4020). Three levels, highest first: + // + // 1. `chart.series[]`'s entry naming this measure — the spec's own per-chart + // override (see `authoredSeriesLabel`); + // 2. the bound dataset's `measures[].label`, which reaches the client as the + // result field's `label` — `headerLabel` reads exactly that (then the i18n + // field-label convention), so the chart and the summary table BENEATH IT + // print one string for one measure instead of two; + // 3. the measure NAME, `headerLabel`'s own last resort. + // + // Level ② is what the dashboard's `buildChartSeries` already did for its own + // chart series (`fields[].label`), and what the table here has always done; + // the report chart was the one surface that consulted neither and shipped the + // raw `name` to a fully-translated console. + const measureLabel = authoredSeriesLabel(chart.series, yAxis, language) ?? headerLabel(yAxis); + if (plan.kind === 'single_value') { - const { measureField, headerLabel } = buildDatasetFieldHelpers(state.fields, state.object, fieldLabel); const mf = measureField(yAxis); return (
@@ -748,7 +802,7 @@ function DatasetReportChart({ {formatMeasure(state.rows[0]?.[yAxis], mf?.format, mf?.currency, mf?.percentScale, displayLocale)} - {headerLabel(yAxis)} + {measureLabel}
); @@ -767,7 +821,12 @@ function DatasetReportChart({ chartType: plan.chartType, data: relabelDimensions(state.rows, dimensionLabels), xAxisKey: xAxis, - series: [{ dataKey: yAxis }], + // One series, carrying its display name. `ChartRenderer` turns a + // series `label` into `config[dataKey].label`, which is the single + // input the legend, the mark's tooltip name and the single-value + // caption all read — so legend and tooltip follow this by + // construction rather than by a second resolution. + series: [{ dataKey: yAxis, label: measureLabel }], height: typeof chart.height === 'number' ? chart.height : 280, // Render deterministically (no rAF entrance animation): reports are // often viewed in a background tab or exported, where an animated diff --git a/packages/plugin-report/src/__tests__/DatasetReportRenderer.chartMeasureLabel.test.tsx b/packages/plugin-report/src/__tests__/DatasetReportRenderer.chartMeasureLabel.test.tsx new file mode 100644 index 0000000000..02bc0ce540 --- /dev/null +++ b/packages/plugin-report/src/__tests__/DatasetReportRenderer.chartMeasureLabel.test.tsx @@ -0,0 +1,276 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#4020 — a report chart printed the raw dataset measure `name` where + * the summary table beneath it, and a dashboard chart over the same dataset, + * both printed the authored `label`. + * + * The renderer handed the chart component `series: [{ dataKey: yAxis }]`: no + * label at all. `ChartRenderer` then fills `config[dataKey].label` with the + * dataKey itself, so the legend, the mark's tooltip name and (on a single-value + * family) the caption all read `potential_upside_tons` under a fully-translated + * console. The authored `chart.series[].label` — declared by + * `ReportChartSchema` since rc.1 — never reached the chart either, which is why + * the card's reporter measured it as inert metadata. + * + * The resolution these cases pin, highest first: + * ① `chart.series[]`'s entry naming the measure, + * ② the bound dataset's measure label (the result field's `label`), + * ③ the measure `name`. + * + * DIRECTIONS, written before the reverse verification was run. "Revert it and + * watch them go red" is ambiguous here, because there are two different ways to + * take the resolution away and they do NOT move the same case — so both were + * predicted, then run, and both matched: + * + * - **name直读** (`measureLabel = yAxis`): 9 red, 1 green. The survivor is ③, + * and necessarily so — reading the name directly COINCIDES with level ③, so + * that case cannot distinguish the two. Every failure printed + * `potential_upside_tons`, i.e. the card's reported symptom verbatim. + * - **the exact pre-change lines** (`series: [{ dataKey }]`, caption + * `headerLabel(yAxis)`): 9 red, 1 green — a DIFFERENT survivor. ③ goes red + * here (the forwarded series carried no `label` key at all, so it is + * `undefined`, not the name), and the green one is "a kpi caption still + * falls back to the dataset measure label" — measured `'120可抢吨位(吨)'`. + * That is not a weak case; it is the record that the single-value branch + * already resolved ② and ③ before this change, and that ① was the only level + * missing there. The series branch resolved none of the three. + * + * So ③'s job is not "red before": it is the guard that the last resort still + * lands on the NAME and did not become `undefined` or `[object Object]` — a + * failure ① and ② cannot see, and the one the pre-change code actually had. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ComponentRegistry } from '@object-ui/core'; +import { I18nProvider } from '@object-ui/i18n'; +import { DatasetReportRenderer } from '../DatasetReportRenderer'; + +/** The card's own dataset: a measure whose authored label is Chinese. */ +const RESULT = { + rows: [ + { account_name: 'Acme', potential_upside_tons: 120 }, + { account_name: 'Globex', potential_upside_tons: 80 }, + ], + fields: [ + { name: 'account_name', type: 'string', label: '客户名称' }, + { name: 'potential_upside_tons', type: 'number', label: '可抢吨位(吨)' }, + ], +}; + +/** The same result with NO field labels — the level ③ shape. */ +const UNLABELLED = { + rows: RESULT.rows, + fields: [ + { name: 'account_name', type: 'string' }, + { name: 'potential_upside_tons', type: 'number' }, + ], +}; + +const sourceOf = (result: unknown) => ({ queryDataset: vi.fn(async () => result) }); + +/** Props the registered chart component was handed, or `null` if never called. */ +let captured: { schema: Record } | null = null; + +beforeEach(() => { + captured = null; + ComponentRegistry.register('chart', (props: any) => { + captured = props; + return null; + }); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +/** The label the chart component is asked to paint for the plotted measure. */ +async function chartSeriesLabel(report: Record, result: unknown = RESULT) { + render(); + await waitFor(() => expect(captured?.schema?.series?.[0]?.dataKey).toBe('potential_upside_tons')); + return captured!.schema.series[0].label; +} + +const BASE = { + name: 'heimao_key_account_attack_list', + type: 'tabular', + dataset: 'heimao_account_metrics', + rows: ['account_name'], + values: ['potential_upside_tons'], +}; + +describe('report chart measure label — three levels (objectui#4020)', () => { + it('① an authored chart.series[].label naming the measure wins', async () => { + // The card measured this exact metadata as inert: authored, rebuilt, + // restarted, still `potential_upside_tons` on the axis. + const label = await chartSeriesLabel({ + ...BASE, + chart: { + type: 'horizontal-bar', + xAxis: 'account_name', + yAxis: 'potential_upside_tons', + series: [{ name: 'potential_upside_tons', label: '本季可抢吨位' }], + }, + }); + // Beats the dataset's own label, which is a DIFFERENT string here on + // purpose: a per-chart override that merely agreed with the dataset would + // pass whether or not it was read. + expect(label).toBe('本季可抢吨位'); + }); + + it('② with no series authored, the dataset measure label is the default', async () => { + const label = await chartSeriesLabel({ + ...BASE, + chart: { type: 'horizontal-bar', xAxis: 'account_name', yAxis: 'potential_upside_tons' }, + }); + expect(label).toBe('可抢吨位(吨)'); + }); + + it('③ with neither, the measure name is the last resort', async () => { + const label = await chartSeriesLabel( + { ...BASE, chart: { type: 'horizontal-bar', xAxis: 'account_name', yAxis: 'potential_upside_tons' } }, + UNLABELLED, + ); + expect(label).toBe('potential_upside_tons'); + }); + + it('a series entry naming ANOTHER measure does not relabel this one', async () => { + // Membership belongs to the report's `values`/`yAxis`; an authored entry + // that names something else is presentation for a series this chart does + // not plot, and must not leak onto the one it does. + const label = await chartSeriesLabel({ + ...BASE, + chart: { + type: 'horizontal-bar', + xAxis: 'account_name', + yAxis: 'potential_upside_tons', + series: [{ name: 'shipped_volume_tons', label: '发货量(吨)' }], + }, + }); + expect(label).toBe('可抢吨位(吨)'); + }); + + it('an entry naming the measure with no usable label falls through to ②', async () => { + const label = await chartSeriesLabel({ + ...BASE, + chart: { + type: 'horizontal-bar', + xAxis: 'account_name', + yAxis: 'potential_upside_tons', + series: [{ name: 'potential_upside_tons' }], + }, + }); + expect(label).toBe('可抢吨位(吨)'); + }); + + it('the FIRST entry naming the measure wins over a later duplicate', async () => { + const label = await chartSeriesLabel({ + ...BASE, + chart: { + type: 'horizontal-bar', + xAxis: 'account_name', + yAxis: 'potential_upside_tons', + series: [ + { name: 'potential_upside_tons', label: '第一次' }, + { name: 'potential_upside_tons', label: '第二次' }, + ], + }, + }); + expect(label).toBe('第一次'); + }); +}); + +describe('report chart measure label — one measure, one string, one screen', () => { + it('the chart series and the summary-table header read the SAME label', async () => { + // The card's diagnosis in one assertion: the two elements sit on one + // screen, over one measure, and disagreed. + render( + , + ); + await waitFor(() => expect(captured?.schema?.series?.[0]?.label).toBeTruthy()); + const headers = (await screen.findAllByRole('columnheader')).map((th) => th.textContent); + expect(headers).toContain('可抢吨位(吨)'); + expect(captured!.schema.series[0].label).toBe('可抢吨位(吨)'); + // And the raw name is nowhere on the screen the chart component paints. + expect(headers).not.toContain('potential_upside_tons'); + }); +}); + +describe('report chart measure label — an i18n label record picks the console language', () => { + it('resolves the zh limb of an authored { en, zh-CN } series label', async () => { + // `ReportChartSchema.series[].label` is the spec's I18nLabel, so the record + // form is as authorable as the string one. Resolving it "first string wins" + // would paint the English limb on a zh console — the defect class this card + // closes, reintroduced by the fix. + render( + + + , + ); + await waitFor(() => expect(captured?.schema?.series?.[0]?.label).toBeTruthy()); + expect(captured!.schema.series[0].label).toBe('可抢吨位'); + }); +}); + +describe('report chart measure label — the single-value families take the same resolution', () => { + it('a kpi caption honours the authored series label over the dataset one', async () => { + // `metric`/`kpi`/`gauge` render the measure as a number with its display + // name beneath; that caption resolved ② and ③ already and skipped ①. One + // resolution now serves both branches, so the two cannot drift. + render( + , + ); + const metric = await screen.findByTestId('dataset-report-metric'); + expect(metric.textContent).toContain('本季可抢吨位'); + expect(metric.textContent).not.toContain('potential_upside_tons'); + }); + + it('a kpi caption still falls back to the dataset measure label', async () => { + render( + , + ); + const metric = await screen.findByTestId('dataset-report-metric'); + expect(metric.textContent).toContain('可抢吨位(吨)'); + }); +});