Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions .changeset/20161-joined-report-chart-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
---
'@objectstack/spec': minor
'@objectstack/lint': patch
'@objectstack/platform-objects': patch
---

fix(spec): a `joined` report draws no chart — `blocks[].chart` is removed and a container `chart` on a joined report is refused (#20161)

Clause-②: no (narrowing)

**BREAKING** — shipped as `minor` under the launch-window convention
(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by
this banner, the `(narrowing)` arm above and the ADR-0087 disposition below,
never by the level).

A `joined` report draws each of its blocks as a table. The renderer's joined
branch returns before its one read of the report's `chart`, and nothing ever
read a block's `chart` at all. So a chart on a joined report, on the container
or on any block, parsed green, passed the `validate-chart-bindings` lint, and
plotted nothing. Both coordinates now answer at parse:

```
FROM ReportSchema.safeParse({ name: 'overview', label: 'Overview', type: 'joined',
chart: { type: 'bar', xAxis: 'status', yAxis: 'task_count' },
blocks: [{ name: 'open_block', dataset: 'tasks', rows: ['status'], values: ['task_count'],
chart: { type: 'pie', xAxis: 'status', yAxis: 'task_count' } }] })
-> { success: true } // both charts silently never drawn

TO -> { success: false, issues: [
{ code: 'unrecognized_keys', path: ['blocks', 0],
message: '… `report.blocks[].chart` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — … Delete the key. …' },
{ code: 'custom', path: ['chart'],
message: 'a `joined` report draws no chart — it draws each block as a table and never reads `chart`, on the container or on a block. Delete `chart`; …' } ] }
```

**Fix.** Delete the `chart`. The report renders exactly as before, because
neither value was ever drawn. To plot one of the slices a block shows, give it a
non-joined report of its own with that `chart`.
`os migrate meta --from 17` lists the mechanical edits for existing sources.

**What does not change.** `chart` on a `tabular` / `summary` / `matrix` report is
untouched: it is that report's live embedded chart. A joined report with no
`chart` parses byte-identically to before, and a block keeps every other key.

### The retirement kit

- **Schema.** `JoinedReportBlockSchema` is closed (`strictObject`), so `chart` is
removed from its shape and answered by its `guidance` table with the
prescription (build-schemas check (c) proof 4). `ReportSchema.chart` stays
declared; the joined arm of its refinement refuses it, beside the
`dataset` / `rows` / `columns` / `values` / `order` refusals already there.
- **ADR-0087.** `RETIRED_KEYS_BY_MAJOR[18]` gains `ui/JoinedReportBlock:chart`, and
the D2 conversion `report-joined-chart-removed` (protocol 18, retired from the
load path) strips a block's `chart` and a joined container's `chart` from old
sources and stored `sys_metadata` rows as a lossless delete. Stored rows can
carry them: the Studio report form offered a block `chart` input until this
change. The family's D3 semantic entry, `ui-report-joined-chart-retired`, states
what the strip cannot decide: whether the chart was wanted. If it was, it moves
to a non-joined report of its own, because a joined report has no chart channel.
- **Form.** `reportForm` drops the block `chart` input and shows the container
`chart` only when `type` is not `joined`; the `platform-objects` metadata-form
translation bundles drop the `blocks.chart` label in all four locales.
- **Lint.** `validate-chart-bindings` no longer resolves the axes of a block chart
or of a joined container's chart against a dataset: it would be vouching for a
chart that is refused at parse and never drawn. A block's own `dataset` /
`rows` / `columns` / `values` are still checked.
- **Ledger and docs.** `liveness/report.json` names a reader for `chart` only on
non-joined reports and drops `chart` from the `blocks` row;
`content/docs/ui/reports.mdx` lists what a joined container refuses.

<!-- adr-0087: registered report-joined-chart-removed, ui-report-joined-chart-retired -->
23 changes: 1 addition & 22 deletions content/docs/references/ui/report.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,33 +31,13 @@ const result = JoinedReportBlockSchema.parse(data);
| **label** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
| **description** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
| **type** | `Enum<'tabular' \| 'summary' \| 'matrix'>` | optional (default: `"tabular"`) | |
| **chart** | `{ type: Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| …>; title?: string \| Record<string, string>; subtitle?: string \| Record<string, string>; description?: string \| Record<string, string>; … }` | optional | |
| **dataset** | `string` | optional | Dataset name to bind (ADR-0021) |
| **rows** | `string[]` | optional | Dimension names down (dataset-bound) |
| **columns** | `string[]` | optional | Dimension names across (matrix, dataset-bound) |
| **values** | `string[]` | optional | Measure names to show (dataset-bound) |
| **runtimeFilter** | `any` | optional | Render-time scope filter (dataset-bound) |
| **order** | `{ by: string; direction: Enum<'asc' \| 'desc'> }[]` | optional | Result ordering, most significant key first |

### Nested Shape: `JoinedReportBlock.chart`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **type** | `Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| …>` | ✅ | |
| **title** | `string \| Record<string, string>` | optional | Chart title |
| **subtitle** | `string \| Record<string, string>` | optional | Chart subtitle |
| **description** | `string \| Record<string, string>` | optional | Accessibility description — announced to screen readers as the chart’s label |
| **xAxis** | `string` | ✅ | Dataset dimension name for the X-axis (bound-dataset dimension, not a raw field) |
| **yAxis** | `string` | ✅ | Dataset measure name for the Y-axis (bound-dataset measure, not a raw field) |
| **series** | `{ name: string; label?: string \| Record<string, string>; type?: Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| …>; color?: string; … }[]` | optional | Defined series configuration. Structure, not appearance — authorable where the chart has inline data; refused by name on a dataset-bound dashboard widget, where the dataset decides it (ADR-0021). |
| **colors** | `string[] \| Record<string, string>` | optional | Color palette (string[]) or value→color map (`{ value: color }`) |
| **height** | `number` | optional | Fixed plot height in pixels (overrides the container default) |
| **showLegend** | `boolean` | optional (default: `true`) | Display legend |
| **showDataLabels** | `boolean` | optional (default: `false`) | Display data labels |
| **annotations** | `{ type: Enum<'line' \| 'region'>; axis: Enum<'x' \| 'y'>; value: number \| string; endValue?: number \| string; … }[]` | optional | Reference lines/bands drawn over the plot: `{ type: "line" \| "region", axis: "x" \| "y", value, endValue?, color?, label?, style? }` |
| **interaction** | `{ tooltips: boolean; brush: boolean }` | optional | Interaction toggles: `{ tooltips?, brush? }` |
| **aria** | `never` | optional | [REMOVED] `ChartConfig.aria` — authored as `dashboard.widgets[].chartConfig.aria`, `report.chart.aria` and `report.blocks[].chart.aria` — was removed in @objectstack/spec 17 (ADR-0049 D2). No chart renderer ever applied it: the chart implementation declares no `aria` prop, the presentation lowering names it nowhere, and the react `<ObjectChart>` block never published it, so ARIA attributes declared here parsed and then silently did not reach the DOM. Delete the key. The accessible name that IS applied on this same chart config is its sibling `description`, which the chart renderer lowers onto the chart graphic as `role="img"` plus `aria-label`. The shared `AriaProps` shape is NOT gone — `ariaLabel` / `ariaDescribedBy` / `role` stay live in the `aria` block on `page.aria`, `page.components[].aria` and the list view `aria`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |

### Nested Shape: `JoinedReportBlock.order[number]`

| Property | Type | Required | Description |
Expand Down Expand Up @@ -85,7 +65,7 @@ const result = JoinedReportBlockSchema.parse(data);
| **runtimeFilter** | `any` | optional | Render-time scope filter |
| **order** | `{ by: string; direction: Enum<'asc' \| 'desc'> }[]` | optional | Result ordering, most significant key first |
| **drilldown** | `boolean` | optional (default: `true`) | Click-through to underlying records |
| **chart** | `{ type: Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| …>; title?: string \| Record<string, string>; subtitle?: string \| Record<string, string>; description?: string \| Record<string, string>; … }` | optional | Embedded chart configuration |
| **chart** | `{ type: Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| …>; title?: string \| Record<string, string>; subtitle?: string \| Record<string, string>; description?: string \| Record<string, string>; … }` | optional | Embedded chart configuration (refused on a joined report, which draws tables only) |
| **blocks** | `{ name: string; label?: string \| Record<string, string>; description?: string \| Record<string, string>; type: Enum<'tabular' \| 'summary' \| 'matrix'>; … }[]` | optional | Sub-reports for type=joined |
| **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this report. |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
Expand Down Expand Up @@ -130,7 +110,6 @@ const result = JoinedReportBlockSchema.parse(data);
| **label** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
| **description** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
| **type** | `Enum<'tabular' \| 'summary' \| 'matrix'>` | optional (default: `"tabular"`) | |
| **chart** | `{ type: Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| …>; title?: string \| Record<string, string>; subtitle?: string \| Record<string, string>; description?: string \| Record<string, string>; … }` | optional | |
| **dataset** | `string` | optional | Dataset name to bind (ADR-0021) |
| **rows** | `string[]` | optional | Dimension names down (dataset-bound) |
| **columns** | `string[]` | optional | Dimension names across (matrix, dataset-bound) |
Expand Down
24 changes: 18 additions & 6 deletions content/docs/ui/reports.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,20 @@ panels over one domain — "open / completed", "new / qualified / closed" — wh
is a different slice rather than a different subject.

A block is a sub-report, so it takes the same `dataset` / `rows` / `columns` / `values` /
`runtimeFilter` / `order` vocabulary. Four things are **container-level only** and are
rejected on a block: nested `blocks` (no recursion — a block's `type` enum excludes
`joined`), `drilldown`, `protection`, and — as below — `order` on a `joined` container.
`runtimeFilter` / `order` vocabulary, and each block is drawn as a table. Three keys are
**container-level only** and are rejected on a block: nested `blocks` (no recursion — a
block's `type` enum excludes `joined`), `drilldown` and `protection`.

The container selects nothing itself, so a `joined` report refuses every top-level key that
selects or orders data and points it onto `blocks[]`: `dataset`, and a non-empty `rows`,
`columns`, `values` or `order`. The container keys a `joined` report does read are
`runtimeFilter` (ANDed into every block's own) and `drilldown`.

**A `joined` report draws no chart.** `chart` on the container is refused with *a `joined`
report draws no chart — it draws each block as a table and never reads `chart`, on the
container or on a block*, and `blocks[].chart` was removed in `@objectstack/spec` 17.5.0:
nothing ever drew either one. To plot one of these slices, give it a report of its own with
a [`chart`](#an-embedded-chart).

{/* os:check */}
```typescript
Expand Down Expand Up @@ -174,9 +185,10 @@ second rejection — write `drilldown: true` / `false` if you mean the report.

## An embedded chart

A report may carry one `chart`. Its `xAxis` and `yAxis` name the **bound dataset's**
dimension and measure — not raw object fields — and are plotted from a second dataset
query, so the chart and the grid cannot disagree.
A non-`joined` report may carry one `chart` (a `joined` report refuses it — see above). Its
`xAxis` and `yAxis` name the **bound dataset's** dimension and measure — not raw object
fields — and are plotted from a second dataset query, so the chart and the grid cannot
disagree.

{/* os:check */}
```typescript
Expand Down
61 changes: 57 additions & 4 deletions packages/lint/src/validate-chart-bindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,14 @@ describe('validateChartBindings — report charts', () => {
expect(findings[0].hint).toContain('Did you mean "task_metrics"?');
});

it('checks a joined report block chart against the block dataset', () => {
// #20161 — a joined report draws no chart: `ReportSchema` refuses a container
// `chart` on one and a block has no `chart` key, and the renderer reads
// neither. Until then this rule resolved a block chart's axes against the
// block's dataset, telling the author the chart was well-bound when it would
// never plot. It is silent about them now — while the SAME block's own
// selection is still resolved, which is what makes the silence a decision
// about the chart rather than a block the walk stopped reaching.
it('does NOT check a joined block\'s chart — nothing draws it — while the block\'s own selection is still resolved', () => {
const findings = validateChartBindings({
...baseStack(),
reports: [
Expand All @@ -206,15 +213,61 @@ describe('validateChartBindings — report charts', () => {
{
name: 'b1',
dataset: 'task_metrics',
rows: ['status_nope'],
values: ['task_count'],
chart: { type: 'pie', xAxis: 'ghost_dim', yAxis: 'task_count' },
chart: { type: 'pie', xAxis: 'ghost_dim', yAxis: 'ghost_measure' },
},
],
},
],
});
expect(findings).toHaveLength(1);
expect(findings[0].path).toBe('reports[0].blocks[0].chart.xAxis');
expect(findings.map((f) => [f.rule, f.path])).toEqual([
[CHART_DIMENSION_UNKNOWN, 'reports[0].blocks[0].rows[0]'],
]);
});

it('does NOT check a joined container\'s chart either — the joined renderer never reads it', () => {
// The container carries a RESOLVABLE `dataset` here on purpose (the schema
// refuses one on a joined report; this rule reads the raw stack). Without
// it the container would bind nothing and return before the chart question
// is ever asked, so the silence below would hold with or without the
// joined guard — a pin that could not fail.
const findings = validateChartBindings({
...baseStack(),
reports: [
{
name: 'joined',
type: 'joined',
dataset: 'task_metrics',
values: ['task_count'],
chart: { type: 'bar', xAxis: 'ghost_dim', yAxis: 'ghost_measure' },
blocks: [{ name: 'b1', dataset: 'task_metrics', rows: ['status'], values: ['task_count'] }],
},
],
});
expect(findings).toEqual([]);
});

it('…and the same unresolvable axes on a NON-joined report still gate — the chart there is drawn', () => {
// The control for both silences above: identical axis names, one level
// up, on the report type whose chart the renderer plots.
const findings = validateChartBindings({
...baseStack(),
reports: [
{
name: 'r',
type: 'summary',
dataset: 'task_metrics',
rows: ['status'],
values: ['task_count'],
chart: { type: 'bar', xAxis: 'ghost_dim', yAxis: 'ghost_measure' },
},
],
});
expect(findings.map((f) => [f.rule, f.path, f.severity])).toEqual([
[CHART_DIMENSION_UNKNOWN, 'reports[0].chart.xAxis', 'error'],
[CHART_MEASURE_UNKNOWN, 'reports[0].chart.yAxis', 'error'],
]);
});

it('checks report series names as measures', () => {
Expand Down
Loading
Loading