Skip to content

Commit 606c32d

Browse files
fix(plugin-report): a report draws a dataset dimension nullLabel for its null bucket (#29334)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbNoZJxAjLuNF5LVf8GtR5wQ)_ Fixes #29010 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:数据集维度声明了 `nullLabel`(如 "No region")时,报表里该维度的空值不再显示 "—",而是显示这个名字。覆盖汇总表的行和分组标题、矩阵的行标题和列标题、报表内嵌图表的空值类目,以及 CSV/XLSX 导出,导出与屏幕上的字一致。没声明 `nullLabel` 的维度照旧显示 "—"。 - **为什么改**:同一个数据集在仪表盘上读作 "No region",在报表里却是 "—",读报表的人分不清"空值"和"缺数据";spec 原文要求渲染器用这个名字画空值,仪表盘早已照做,报表没有。 - **风险与回滚**:低。只改 `plugin-report` 一个文件的显示文字;分组、合计、钻取仍读原始值,非空值(包括空字符串)显示不变。回滚即 revert 本 PR。 - **先看**:① `ui/packages/plugin-report/src/DatasetReportRenderer.tsx` 新增的 `dimensionText` 及其六处调用;② 同文件的 `declaredNullCategoryLabel`(图表空值类目,照抄仪表盘规则:透视图两个维度声明同一个名字才用);③ 新测试 `DatasetReportRenderer.nullLabel-29010.test.tsx`。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/plugin-report/src/__tests__/DatasetReportRenderer.nullLabel-29010.test.tsx`,预期 8 个测试全绿(空值行、分组标题、矩阵行/列标题、CSV、图表类目都读 "No region (R8)",未声明的维度仍是 "—")。 - **需要你做的**:无,席位落地。 ## What changes A dataset dimension can name its null bucket with `nullLabel`, and the dataset query answer carries it on the column descriptor, `fields[].nullLabel`, already resolved to the request locale. The spec (`packages/spec/src/api/analytics.zod.ts`, `nullLabel`) says: "A renderer draws the column's null values under it instead of its own default." The dashboard widget did (`DatasetWidget.tsx`, `nullLabelOf`); the report drew `formatDimensionValue`'s "—" everywhere. `ui/packages/plugin-report/src/DatasetReportRenderer.tsx` only: - **`dimensionText(fields, dimension, value, locale)`** (module-local): a null value reads as the column's declared `nullLabel` when the answer carries one; otherwise, and for every non-null value, it is `formatDimensionValue(value, locale)` unchanged (an empty string stays an empty string). It reads the label off the answer the report already holds (`state.fields`), the way the dashboard widget does; nothing is resolved from metadata a second time, and `@object-ui/core`'s `formatDimensionValue` is untouched. - It replaces `formatDimensionValue` at every site the report draws a dimension value: the summary table's detail cells and their export cells, the grouped summary's group headers and their export cells, the matrix row headers and their export cells, and `bucketLabel` (the matrix row/column header labels, so the across-axis headers and their export header cells too). - **The report's embedded chart** (an extra site, agreed with the seat in the card thread): its null category was `tt('chart.nullCategory', '(None)')`. It now takes `declaredNullCategoryLabel(state.fields, queryDimensions, [yAxis])` first, a plugin-report-local copy of the dashboard widget's rule: the x-axis dimension's label, and on a `seriesBy` pivot only when both dimensions declare the same one; otherwise "(None)" as before. The same resolved label keeps the empty series last in the series ordering. - Bucket ids, cell lookups, drill payloads and grouping read the raw value as before; this is display copy only. ## Tests New `ui/packages/plugin-report/src/__tests__/DatasetReportRenderer.nullLabel-29010.test.tsx` (8 tests, real `DatasetReportRenderer` over a `queryDataset` double whose answer declares `nullLabel` on `region` and none on `kind`): - summary: the null row reads the label on screen, and the CSV equals the drawn table; - control: a column without `nullLabel` keeps "—", and an empty-string value stays blank, on screen and in the CSV; - grouped summary: the null group header reads the label (the inner undeclared null keeps "—"), CSV equals the drawn table; - matrix: the null row header reads the label while an undeclared across-axis null header keeps "—", CSV equal; - matrix: the null across-axis header reads the label, CSV header equal; - chart: the x-axis null category reads the label; control: an undeclared x-axis keeps "(None)"; a `seriesBy` pivot takes the label only when both dimensions declare the same one. Ablations (subject imported from `src` by relative path, so no build leg; each run under `os-verify-lock`, restored and proved by blob hash equal to `HEAD` and an empty `git diff HEAD`): - the whole renderer reverted to `origin/main` (blob `cd8213a1`, `dimensionText(` count 0) at `481cc948`: 4 red, the "—" control green; - the chart site only, through `scripts/ablation-replace.mjs` (anchor 1 to 0, blob `0b68fe40` to `b77c6c08`) at `345a95fb`: 2 red (x-axis label, pivot), the other 6 green. ## Acceptance notes - **Measured on a real backend** (`examples/app-crm` under `objectstack dev --seed-admin --fresh`, the Vite source Console proxying to it, Chromium). Scratch package `app.qar8` with object `qar8_dsobj`, dataset `qar8_dataset` (`region` with `nullLabel: 'No region (R8)'`, `kind` without), reports `qar8_report` (summary), `qar8_matrix` (rows region, columns kind), `qar8_matrix_across` (rows kind, columns region) and `qar8_chart` (summary with a bar chart on region); rows seeded with region null. The page's own query answer carried `fields.region.nullLabel = "No region (R8)"`. | report | before (origin/main) | after (this branch) | |---|---|---| | `qar8_report` screen / CSV | `— \| 2 \| 37` / `—,2,37` | `No region (R8) \| 2 \| 37` / `No region (R8),2,37` | | `qar8_matrix` row header / CSV | `—` / `—,30,7,—,37` | `No region (R8)` / `No region (R8),30,7,—,37` (the `kind` null column header stays `—`) | | `qar8_matrix_across` column header / CSV header | `Kind,—,North,South,Total` | `Kind,No region (R8),North,South,Total` (the `kind` null row header stays `—`) | | `qar8_chart` x-axis ticks | `(None)`, North, South | `No region (R8)`, North, South | The export leg needed one harness step: the dev admin holds no `allowExport` grant (the built-in admin sets never carry it, by design), so the browser's `/me/permissions` answer was patched to add `export` on the scratch object. The CSV itself is the renderer's own snapshot. - The report chart site was not in the card; the seat agreed in the card thread to fold it in, as the same defect in the same file. - `formatDimensionValue` in `@object-ui/core` and `plugin-dashboard` are untouched; `declaredNullCategoryLabel` is a local copy of the dashboard's rule rather than a new shared helper. - eslint on the two touched files: 0 errors; the renderer's 5 `react-refresh` warnings are the same count as on main; the new test file carries 8 `no-explicit-any` warnings on chart-prop reads, as the sibling chart tests do. ## Verification Head `ae73251f`: origin/main merged twice (`39951d2a` at `3b3fef03`, `6dd6a534` at `ae73251f`), both clean; neither brought a change to `ui/packages/plugin-report` or `ui/packages/core`, and no parallel `nullLabel` fix exists on main. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 34 commands, all exit 0 at `5ed3317b` (the same 3 paths as the head); `--ran` reconciles 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN. - `pnpm --dir ui exec vitest run packages/plugin-report/` at the head: 33 files, 307 passed, 7 skipped (the new file's 8 included). - `pnpm exec turbo run type-check --filter=@object-ui/plugin-report` at the head: 48 tasks, green. - `pnpm --dir ui exec vitest run scripts/` at `5ed3317b`: 169 files passed, 2 skipped. `node scripts/check-sdui-manifest.mjs` at the head: 0. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (eslint on every touched file: 0 errors; the repo-wide run is CI's) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QsR2jUQvckLeFmnn4neyq3 --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jUQvckLeFmnn4neyq3)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8dceec1 commit 606c32d

3 files changed

Lines changed: 407 additions & 10 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/plugin-report': patch
3+
---
4+
5+
fix(plugin-report): a report draws a dataset dimension's `nullLabel` for its null bucket
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A dataset dimension can name its null bucket with `nullLabel` ("No region"), and the dataset query answer carries it on the column, `fields[].nullLabel`. The dashboard widget already drew it, but a report drew the same bucket as "—", so one dataset read "No region" on a dashboard and "—" in a report.
11+
12+
The report now draws the declared label wherever it draws a null dimension value: the summary table's rows and group headers, the matrix's row and column headers, and the CSV and XLSX export cells, so a download says the same words as the screen. A dimension that declares no `nullLabel` keeps "—", and every non-null value, an empty string included, is drawn as before. Grouping, totals and drill-through are unchanged: they still read the raw value.

‎ui/packages/plugin-report/src/DatasetReportRenderer.tsx‎

Lines changed: 75 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1022,7 +1022,7 @@ function DatasetReportTable({
10221022
// A grouped detail row leaves its outer dimensions blank: the group header
10231023
// above it names them once.
10241024
const detailDimension = (c: string, i: number) =>
1025-
grouped && rows.indexOf(c) < rows.length - 1 ? '' : formatDimensionValue(displayRows[i][c], displayLocale);
1025+
grouped && rows.indexOf(c) < rows.length - 1 ? '' : dimensionText(state.fields, c, displayRows[i][c], displayLocale);
10261026
// The cells below, as the export writes them (`ReportExport.tsx`).
10271027
const snapshot: ReportTableSnapshot = {
10281028
object: state.object,
@@ -1033,7 +1033,7 @@ function DatasetReportTable({
10331033
if (line.kind === 'header') {
10341034
return columns.map((_, p) =>
10351035
p === line.level
1036-
? exportDimensionCell(formatDimensionValue(displayRows[i][rows[line.level]], displayLocale))
1036+
? exportDimensionCell(dimensionText(state.fields, rows[line.level], displayRows[i][rows[line.level]], displayLocale))
10371037
: { text: '' },
10381038
);
10391039
}
@@ -1076,7 +1076,7 @@ function DatasetReportTable({
10761076
<tr key={`h-${line.level}-${i}`} className="border-t bg-muted/10 font-medium" data-testid="dataset-report-group-header">
10771077
{line.level > 0 && <td colSpan={line.level} />}
10781078
<td colSpan={columns.length - line.level} className="px-2 py-1 whitespace-nowrap">
1079-
{formatDimensionValue(displayRows[i][rows[line.level]], displayLocale)}
1079+
{dimensionText(state.fields, rows[line.level], displayRows[i][rows[line.level]], displayLocale)}
10801080
</td>
10811081
</tr>
10821082
);
@@ -1620,7 +1620,11 @@ function DatasetReportChart({
16201620
categoryRank ? { [xAxis]: categoryRank } : null,
16211621
);
16221622
const plotRows = plotLayout ? plotLayout.map((i) => displayRows[i]) : displayRows;
1623-
const nullCategoryLabel = tt('chart.nullCategory', '(None)');
1623+
// The null bucket's name: the dataset's declared `nullLabel` when the answer
1624+
// carries one the chart can use (`declaredNullCategoryLabel`), else the
1625+
// chart's own default — as the dashboard widget names it.
1626+
const nullCategoryLabel =
1627+
declaredNullCategoryLabel(state.fields, queryDimensions, [yAxis]) ?? tt('chart.nullCategory', '(None)');
16241628
const { data: chartData, xAxisKey, series: pivotOrDerivedSeries } = buildChartSeries(
16251629
plotRows,
16261630
queryDimensions,
@@ -1737,6 +1741,59 @@ function DatasetReportChart({
17371741
);
17381742
}
17391743

1744+
/**
1745+
* The text a report draws for one dimension value: on screen, in a group
1746+
* header, in a cross-tab header and in the export cell beside it.
1747+
*
1748+
* A null value reads as the dimension's declared `nullLabel` when the query
1749+
* answer carries one on that column's descriptor (`fields[].nullLabel`, already
1750+
* resolved to the request locale by the server, objectstack#25391); the spec
1751+
* says "a renderer draws the column's null values under it instead of its own
1752+
* default". It is read off the answer the way the dashboard widget reads it
1753+
* (`DatasetWidget.tsx`, `nullLabelOf`), never resolved from metadata a second
1754+
* time. A column that declares none keeps `formatDimensionValue`'s default, and
1755+
* every non-null value (an empty string included) formats exactly as before.
1756+
* Display copy only: bucket ids, drill keys and grouping read the raw value.
1757+
*/
1758+
function dimensionText(
1759+
fields: readonly DatasetResultField[] | undefined,
1760+
dimension: string,
1761+
value: unknown,
1762+
locale?: string,
1763+
): string {
1764+
if (value == null) {
1765+
const declared = fields?.find((f) => f.name === dimension)?.nullLabel;
1766+
if (typeof declared === 'string') return declared;
1767+
}
1768+
return formatDimensionValue(value, locale);
1769+
}
1770+
1771+
/**
1772+
* The name the dataset DECLARES for a report chart's null bucket, or
1773+
* `undefined` when it declares none — the dashboard widget's rule
1774+
* (`DatasetWidget.tsx`, `declaredNullCategoryLabel`), read off the same
1775+
* answer as {@link dimensionText}.
1776+
*
1777+
* `buildChartSeries` draws every null bucket of one chart under ONE label: the
1778+
* x-axis dimension's, and on a `seriesBy` pivot (two dimensions, one measure)
1779+
* the series dimension's too. So a pivot takes a declared name only when both
1780+
* of its dimensions declare the same one; any other pivot keeps the default
1781+
* rather than naming one dimension's null group with the other's label.
1782+
*/
1783+
function declaredNullCategoryLabel(
1784+
fields: readonly DatasetResultField[] | undefined,
1785+
dimensions: readonly string[],
1786+
measures: readonly string[],
1787+
): string | undefined {
1788+
const nullLabelOf = (dimension: string | undefined) =>
1789+
dimension === undefined ? undefined : fields?.find((f) => f.name === dimension)?.nullLabel;
1790+
const xLabel = nullLabelOf(dimensions[0]);
1791+
if (dimensions.length >= 2 && measures.length === 1) {
1792+
return xLabel !== undefined && xLabel === nullLabelOf(dimensions[1]) ? xLabel : undefined;
1793+
}
1794+
return xLabel;
1795+
}
1796+
17401797
/**
17411798
* Stable bucket id for a dimension-value tuple — the id every lookup in the
17421799
* cross-tab below is keyed by (row headers, column headers, and both subtotal
@@ -1766,9 +1823,17 @@ function bucketId(dims: string[], row: Row): string {
17661823
* {@link bucketId}, which is built from the RAW values and is deliberately
17671824
* untouched by the locale, so a locale change can never re-key a cell (which
17681825
* would break the `cells` lookup and drill-through with it).
1826+
*
1827+
* `fields` is the query answer's column descriptors: a null value reads as its
1828+
* dimension's declared `nullLabel` ({@link dimensionText}).
17691829
*/
1770-
function bucketLabel(dims: string[], row: Row, locale?: string): string {
1771-
return dims.map((d) => formatDimensionValue(row[d], locale)).join(' / ');
1830+
function bucketLabel(
1831+
dims: string[],
1832+
row: Row,
1833+
fields: readonly DatasetResultField[] | undefined,
1834+
locale?: string,
1835+
): string {
1836+
return dims.map((d) => dimensionText(fields, d, row[d], locale)).join(' / ');
17721837
}
17731838

17741839
/**
@@ -1866,14 +1931,14 @@ function DatasetMatrixTable({
18661931
const key: Row = {};
18671932
const display: Row = {};
18681933
for (const d of rows) { key[d] = raw[d]; display[d] = r[d]; }
1869-
rowHeaders.push({ id: rid, label: bucketLabel(rows, r, displayLocale), key, display });
1934+
rowHeaders.push({ id: rid, label: bucketLabel(rows, r, state.fields, displayLocale), key, display });
18701935
}
18711936
if (!seenCol.has(cid)) {
18721937
seenCol.add(cid);
18731938
const key: Row = {};
18741939
const display: Row = {};
18751940
for (const d of columnsAcross) { key[d] = raw[d]; display[d] = r[d]; }
1876-
colHeaders.push({ id: cid, label: bucketLabel(columnsAcross, r, displayLocale), key, display });
1941+
colHeaders.push({ id: cid, label: bucketLabel(columnsAcross, r, state.fields, displayLocale), key, display });
18771942
}
18781943
// Keyed by pivotCellKey, not `${rid} ${cid}`: a plain space is a boundary
18791944
// only while no dimension value contains one, and they do constantly
@@ -1987,7 +2052,7 @@ function DatasetMatrixTable({
19872052
],
19882053
rows: [
19892054
...pivot.rowHeaders.map((rh) => [
1990-
...rows.map((d) => exportDimensionCell(formatDimensionValue(rh.display[d], displayLocale))),
2055+
...rows.map((d) => exportDimensionCell(dimensionText(state.fields, d, rh.display[d], displayLocale))),
19912056
...cellCols.map((cc) => {
19922057
const value = pivot.cells.get(pivotCellKey(rh.id, cc.col.id))?.row[cc.measure];
19932058
return exportMeasureCell(fmt(cc.measure, value), value);
@@ -2041,7 +2106,7 @@ function DatasetMatrixTable({
20412106
<tr key={rh.id} className="border-t">
20422107
{rows.map((d) => (
20432108
<td key={d} className="px-2 py-1 whitespace-nowrap font-medium">
2044-
{formatDimensionValue(rh.display[d], displayLocale)}
2109+
{dimensionText(state.fields, d, rh.display[d], displayLocale)}
20452110
</td>
20462111
))}
20472112
{cellCols.map((cc) => {

0 commit comments

Comments
 (0)