diff --git a/.changeset/22491-chart-list-view-binds-a-dataset.md b/.changeset/22491-chart-list-view-binds-a-dataset.md new file mode 100644 index 00000000000..e0ca0c962d5 --- /dev/null +++ b/.changeset/22491-chart-list-view-binds-a-dataset.md @@ -0,0 +1,33 @@ +--- +'@objectstack/spec': major +--- + +A `type: 'chart'` list view must bind a dataset: a view whose effective chart binding names no `dataset` is refused at every list-view door, at `chart` (no binding at all) or at `options.chart.dataset` / `options.chart.values` (an incomplete legacy bag), with the binding to declare. + +Clause-②: yes (narrowing) + + + +**BREAKING**: an accept-set narrowing on a published authoring surface and on the view write door, graded `major` on `@objectstack/spec`: Changesets is in pre mode on `main` (tag `next`), where the launch-window `major` guard stands aside for the line's breaking changes. + +**Why.** A chart list view plots only the ADR-0021 `dataset` its binding names. The renderer reads that binding as the top-level `chart` block, else the legacy `options.chart` bag, the block replacing the bag whole. The `chart` block already required `dataset` and `values`, but a view with no block at all, or with only the bag, never met that schema: the view write door (`PUT /api/v1/meta/view/:name`) saved `type: 'chart'` with no `chart` block, and an `options.chart` bag holding only `chartType`, and `defineStack` / `os validate` accepted the block-less view. Such a view renders a dead screen: the renderer either guessed a binding nobody wrote or, since objectui retired that guess, refuses on screen. + +**What is refused.** A list view whose `type` is `chart` and that: + +- declares no `chart` block and no `options.chart` bag: one `custom` issue at `chart`, whose message begins *This list view is `type: 'chart'` but declares no `chart` block, so it binds no dataset and there is nothing to plot.*; +- declares no `chart` block and an `options.chart` bag with no `dataset` or no `values` (the bag is legal on the flattened overlay only): one `custom` issue per missing key, at `options.chart.dataset` / `options.chart.values`. + +It is a check on the list-view schema itself, so it reaches every door that parses a list view: `defineView`, `defineStack`, `os validate` / `os build`, a view item's `config`, and the metadata write door, which answers `422 INVALID_METADATA`. **New export:** `checkListViewChartBinding`, published from `@objectstack/spec/ui`, is this refinement check itself, a `(view, ctx) => void` function; objectui's `ListViewSchema` mirror, which is built from `ListViewSchema.shape` and so drops the schema's object-level checks, attaches it with `.superRefine(checkListViewChartBinding)`. + +**What stays accepted, byte for byte.** A chart view whose `chart` block names a `dataset` and at least one measure in `values`; a chart overlay whose `options.chart` bag carries both and no `chart` block replaces it; an incomplete bag under a complete `chart` block, which replaces it whole; a flattened overlay patch that names no `type`; and every view of another type, including a grid that only offers a chart in `appearance.allowedVisualizations`. + +**What to do.** Bind the chart: declare a top-level `chart` block naming the dataset to plot and at least one of its measures, for example `chart: { dataset: 'lead_metrics', values: ['amount_sum'] }`, with `dimensions` (the X / group axis) optional and `chartType` defaulting to `bar`. A view that carries its binding in the legacy `options.chart` bag either completes the bag or, preferred, moves it to the top-level `chart` block. A view that is not meant to be a chart takes another `type`. No conversion can do this for you: only the author knows which dataset a chart plots. + +**Stored views.** A stored `view` row is neither rewritten nor refused on read: it is served as stored, carries the same issue in its read-side `_diagnostics`, and is refused on its next save. + +**Who is affected, measured.** No chart list view without a binding exists in this repository: the two chart list views in `examples/app-showcase` and the chart list views in the `@objectstack/lint` fixtures all bind a dataset and a measure. Deployed metadata was not measured. + +### The kit + +- **The refusal.** `checkListViewChartBinding` in `ui/view.zod.ts`, attached beside the calendar binding check at the three list-view doors: `ListViewSchema`, `ObjectListViewSchema` and the flattened list overlay member of the view write door. The `chart` slot's description now says a chart view must bind one, and the generated reference page carries it. +- **The ledger.** The D3 semantic entry `view-chart-binding-dataset-required` (protocol 18). No key is removed, so there is no tombstone, and there is no D2 conversion. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 163ede93376..e60ee7b49fa 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1689,7 +1689,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | -| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | +| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | Chart binding — applies when the view renders as a chart. A `type: 'chart'` view must bind one: it names the ADR-0021 `dataset` and the measures (`values`) the chart plots, and there is no default binding | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout | | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -1774,7 +1774,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | -| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | +| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | Chart binding — applies when the view renders as a chart. A `type: 'chart'` view must bind one: it names the ADR-0021 `dataset` and the measures (`values`) the chart plots, and there is no default binding | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout | | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index f6ff2b8f5ba..3c6f1ce61a1 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -385,7 +385,7 @@ const result = ApiMethod.parse(data); | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | -| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | +| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | Chart binding — applies when the view renders as a chart. A `type: 'chart'` view must bind one: it names the ADR-0021 `dataset` and the measures (`values`) the chart plots, and there is no default binding | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout | | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index ad52bcbc453..50e263d2dbe 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -785,7 +785,7 @@ Map view configuration | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | -| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | +| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | Chart binding — applies when the view renders as a chart. A `type: 'chart'` view must bind one: it names the ADR-0021 `dataset` and the measures (`values`) the chart plots, and there is no default binding | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout | | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -1177,7 +1177,7 @@ View filter rule | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | -| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | +| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | Chart binding — applies when the view renders as a chart. A `type: 'chart'` view must bind one: it names the ADR-0021 `dataset` and the measures (`values`) the chart plots, and there is no default binding | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout | | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -1760,7 +1760,7 @@ Tab configuration for multi-tab view interface | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | -| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | +| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | Chart binding — applies when the view renders as a chart. A `type: 'chart'` view must bind one: it names the ADR-0021 `dataset` and the measures (`values`) the chart plots, and there is no default binding | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout | | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -1845,7 +1845,7 @@ Tab configuration for multi-tab view interface | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | -| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | +| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | Chart binding — applies when the view renders as a chart. A `type: 'chart'` view must bind one: it names the ADR-0021 `dataset` and the measures (`values`) the chart plots, and there is no default binding | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout | | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -2086,7 +2086,7 @@ This schema accepts one of the following structures: | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | -| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | +| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | Chart binding — applies when the view renders as a chart. A `type: 'chart'` view must bind one: it names the ADR-0021 `dataset` and the measures (`values`) the chart plots, and there is no default binding | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout | | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -2264,7 +2264,7 @@ This schema accepts one of the following structures: | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | -| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | +| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | Chart binding — applies when the view renders as a chart. A `type: 'chart'` view must bind one: it names the ADR-0021 `dataset` and the measures (`values`) the chart plots, and there is no default binding | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout | | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 7bef8ddfa44..0d151cabc0d 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -1542,6 +1542,9 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-regis - **`ui-report-joined-container-selection-refused`** — `report selection keys on a `joined` container — a top-level `dataset`, or a NON-EMPTY top-level `rows` / `columns` / `values` list, on a report whose `type` is `joined` (`ReportSchema`'s refinement)` → the same key on the `blocks[]` entries that need it — each block binds its own `dataset` and selects its own `rows` / `columns` / `values` — or DELETE it. Deleting changes nothing that renders: the container value was never read. The refusal lands at the key's own path and says both, the way the container `order` refusal beside it always has, and that `order` refusal is unchanged. - Why not automatic: ADR-0049 enforce-or-remove, the enforce arm: the four keys stay declared (they are the selection of every non-joined report), and the one report type that never reads them now refuses them. A `joined` report selects nothing itself, and the refinement already said so for `order` alone — it refused a container `order` with a pointer onto `blocks[]` while the four selection keys beside it parsed green. Measured at this repo's `.objectui-sha` pin `f8a9d0fb0596f4521076628e2bbfe27e6ce67d52`: `DatasetReportRenderer`'s joined branch (`DatasetReportRenderer.tsx:1462`) reads `blocks`, plus the container `runtimeFilter` and `drilldown` resolved above it, and returns before the top-level reads of `columns` / `dataset` / `rows` / `values` begin (line 1529 onward) — so each was accepted by the metadata layer and dropped by the renderer without a word. The alias tables made it reachable: `fields` / `measures` / `metrics` route to `values`, `groupings` / `groupBy` / `dimensions` to `rows`, and `objectName` / `object` / `dataSet` / `source` to `dataset`, on a joined report as on any other. Studio's report inspector hides the top-level binding for a joined report (`ReportDefaultInspector.tsx:328`) but its type picker patches only `type`, so a report bound first and switched to `joined` second carries the keys invisibly. An empty list is NOT refused: it selects nothing, which is what a joined container selects — the container `order` refusal's own threshold. Ships at once, no deprecation window: there is no window in which a key the renderer never reads does anything. - Done when: WHICH DOOR: this is the spec schema's refusal, so it lands wherever a report is parsed through `@objectstack/spec` — `defineReport`, `os validate` / `os build`, and the metadata save door (the `report` entry of the metadata type registry) — as one `custom` issue per key at `dataset` / `rows` / `columns` / `values`. A stored `sys_metadata` report row is not rewritten: it carries the same issue in its read-side `_diagnostics` and is refused on its next save. Fix each by moving the key onto the blocks that need it or deleting it, then check the rendered report: it renders exactly as before, because the container value was never read. A joined report that carries only `blocks`, `runtimeFilter`, `drilldown` and the identity / protection keys parses byte-identically to before, and every non-joined report is untouched. Census at the time of the change: zero joined reports carry any of the four at the container — in this repo one example-app report, one docs example and five test fixtures across `packages/lint` and `packages/platform-objects`; in objectui every joined-report fixture and docs example at the pin above (13 occurrences); in the cloud repo none exist. +- **`view-chart-binding-dataset-required`** — `A list view whose type is chart and whose effective chart binding names no dataset: no chart block and no options.chart bag, or, on a flattened view overlay saved through the metadata write door, an options.chart bag missing its dataset or its values while no chart block replaces it. Judged at every list-view door: views[].list and views[].listViews, objects[].listViews, a view item config, and the flattened list overlay.` → Bind the chart: declare a top-level `chart` block naming the ADR-0021 `dataset` to plot and at least one of its measures in `values` (`dimensions`, the X / group axis, stays optional, and `chartType` defaults to `bar`). A view whose only binding is the legacy `options.chart` bag completes the bag with `dataset` and `values`, or, preferred, moves the binding to the top-level `chart` block, which replaces the bag whole. A view that is not meant to be a chart takes another `type`. + - Why not automatic: ADR-0021 single form, enforced (ADR-0049 enforce-or-remove, the enforce arm; ADR-0078, a view that renders nothing is refused rather than warned). A chart list view plots only the dataset its effective binding names, and the renderer reads that binding as the `chart` block, else the `options.chart` bag, the block replacing the bag whole (objectui plugin-list `ListView`, `resolveListChartBinding`, at this repo's `.objectui-sha` pin and at objectui main alike). The authoring `chart` block already required `dataset` and `values`, but a view with no block at all, or with only the bag, never met that schema. Measured on `origin/main` at `e148ca98`: the flattened overlay member accepted `type: 'chart'` with no block and an `options.chart` bag holding only `chartType`, and both authoring doors accepted the block-less view. What such a view rendered was a dead screen: at the pin the renderer fabricated a binding nobody wrote (an aggregate over a field named name and a measure named value), and objectui has since retired that floor, after which the chart component refuses on screen. Now refused at the view's own path, `chart`, or at `options.chart.dataset` / `options.chart.values`, with the binding to declare. Ships at once, no grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). Not convertible: only the author knows which dataset a chart was meant to show. + - Done when: WHICH DOOR: the spec schema's refusal, so it lands wherever a list view is parsed through `@objectstack/spec` — `defineView`, `defineStack`, `os validate` / `os build`, and the metadata write door (`PUT /api/v1/meta/view/:name`, answering `422 INVALID_METADATA`) — as one `custom` issue at `chart` for a view with no binding at all, or one per missing key at `options.chart.dataset` / `options.chart.values` for an incomplete bag (overlay only; the authoring doors refuse `options` by name). A stored `sys_metadata` view row is neither rewritten nor refused on read: measured, the read door serves it as stored with the same issue in its `_diagnostics`, and it is refused on its next save. Fix each chart view by declaring its binding, then open it: it plots the dataset. A chart view that already declares a complete `chart` block parses byte-identically to before, and every view of another type is untouched, a grid that only offers a chart in `allowedVisualizations` included. Census at the time of the change: the two chart list views in `examples/app-showcase` and the chart list views in the `packages/lint` fixtures all bind a dataset and a measure; the only spec test that parsed a block-less chart view was a type-acceptance pin, re-judged in the same change. - **`view-filter-rule-absent-value-refused`** — `ui.ViewFilterRule with NO value on an operator that takes one — the value key omitted, or present and undefined, on equals, not_equals, contains, not_contains, icontains, starts_with, ends_with, greater_than, less_than, greater_than_or_equal, less_than_or_equal, before or after (an alias spelling of any of them included), on every carrier of ViewFilterRuleSchema` → the value the rule compares against — value: "open" on equals, value: "2026-01-01" on after. A rule that meant "the field has no value" becomes one of the four operators that take none — is_empty / is_not_empty / is_null / is_not_null — which read their direction from their name and still parse with or without a value. A rule that was an unfinished row is deleted. The list operators (in / not_in) and the range operator (between) refused an absent value before this change and still do, in their own words - Why not automatic: The value key's own published description has declared, since the value was first shaped by its operator, that every operator outside the list, range and unary sets takes a scalar, and that only the unary operators ignore the key; the refinement implementing the coupling returned early on an absent value for every operator, so a rule with no value parsed green on all thirteen scalar operators. The query path refuses the same rule: both lowerings of a stored rule — the console's and the REST lookup-picker route's — emit it as the two-element [field, operator] node, which the filter-AST lowering reads as an undefined comparand and refuses with INVALID_FILTER / 400, measured for all thirteen operators. Nothing between storage and the query drops the rule, so one such rule failed every query that read its view, the view's other rules included. The first-party producer does not write the shape: the console filter builder drops a row whose operator takes a value and whose value is missing before it saves, and the drill-down save-as-view path checks each rule against this schema before persisting it (read at the pinned objectui commit). Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: there is no value to infer, and writing a value, switching to a unary operator and deleting the rule are three different predicates only the author can choose between. The read path does not re-validate stored rows (the reading the sibling entry view-filter-rule-scalar-operator-array-refused records), so a stored view keeps loading — and keeps failing its queries, as it did before this change; what changes is that RE-SAVING it is refused at the value path, naming the operator and the field. ADR-0049 / ADR-0087 / ADR-0112. - Done when: Grep your authored views, pages and object-* blocks for a filter rule that has no value key and whose operator is none of the four unary operators, then decide per rule which of three things it meant: a comparison (write the value), a test for emptiness (switch to is_empty / is_not_empty / is_null / is_not_null), or an unfinished row (delete it). os validate reports each one by path with the operator and the field, so the sweep is mechanical rather than by eye. A view carrying one of these rules was refusing every query before this change, so re-check what it is supposed to show rather than assuming any earlier result set. diff --git a/packages/rest/src/meta-view-chart-binding.test.ts b/packages/rest/src/meta-view-chart-binding.test.ts new file mode 100644 index 00000000000..b0086f4e0b3 --- /dev/null +++ b/packages/rest/src/meta-view-chart-binding.test.ts @@ -0,0 +1,185 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22491 — `PUT /api/v1/meta/view/:name`, the door a Studio tenant or an MCP/AI + * author writes through, refuses a `type: 'chart'` list view whose effective + * binding names no dataset, on the real composition: the real `RestServer` + * route over the real `saveMetaItem`, backed by a real `ObjectQL` + SQLite + * `sys_metadata`. + * + * Before the change (measured on `origin/main` @ `e148ca98`): the flattened + * list overlay member accepted `type: 'chart'` with no `chart` block, and an + * `options.chart` bag holding only `chartType`. + * + * Refusal cases assert the ADR-0112 envelope — `code` AND `status` — and that + * no row reached `sys_metadata`. The last case measures the READ side of the + * narrowing on a row stored before it: served as stored, flagged in its + * `_diagnostics`, and refused on its next save. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { + SysMetadata, + SysMetadataHistoryObject, + SysMetadataAuditObject, +} from '@objectstack/platform-objects/metadata'; +import { RestServer } from './rest-server.js'; + +const META_ITEM = '/api/v1/meta/:type/:name'; + +const liveEngines: ObjectQL[] = []; +afterEach(async () => { + while (liveEngines.length) { + try { await liveEngines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +function createMockServer() { + const noop = () => {}; + return { + get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, + listen: async () => {}, close: async () => {}, + }; +} + +function makeRes() { + const res: any = { + _status: 200, + write: () => true, end: () => {}, send: () => res, setHeader: () => {}, + header: () => res, + status: (code: number) => { res._status = code; return res; }, + json: (body: any) => { res._json = body; return res; }, + }; + return res; +} + +async function boot() { + const engine = new ObjectQL(); + liveEngines.push(engine); + engine.registerDriver(new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }), true); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.metadata-objects', + name: 'Metadata Platform Objects', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [SysMetadata, SysMetadataHistoryObject, SysMetadataAuditObject], + }); + await engine.syncSchemas(); + + const protocol = new ObjectStackProtocolImplementation(engine as any); + const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); + // The route demands `manage_metadata`; the caller holds it, so a refusal + // here is the spec door's and nothing else's. + (rest as any).resolveExecCtx = async () => ({ userId: 'u_author', systemPermissions: ['manage_metadata'] }); + rest.registerRoutes(); + const routeFor = (method: string) => { + const route = rest.getRoutes().find((r: any) => r.method === method && r.path === META_ITEM); + if (!route) throw new Error(`${method} ${META_ITEM} is not registered`); + return route; + }; + const call = async (method: string, name: string, body?: unknown) => { + const res = makeRes(); + await routeFor(method).handler({ params: { type: 'view', name }, query: {}, headers: {}, body } as any, res); + return res; + }; + const storedRows = async (name: string) => engine.find('sys_metadata', { where: { type: 'view', name } }); + return { engine, put: (name: string, body: unknown) => call('PUT', name, body), get: (name: string) => call('GET', name), storedRows }; +} + +const BINDING = { chartType: 'bar', dataset: 'lead_metrics', dimensions: ['stage'], values: ['amount_sum'] }; +const chartView = (name: string, extra: Record) => ({ + name, + object: 'crm_lead', + viewKind: 'list', + label: 'Pipeline by stage', + type: 'chart', + columns: ['stage', 'amount'], + ...extra, +}); + +type Issue = { path?: string; code?: string; message?: string }; +const issuesOf = (res: any): Issue[] => (res._json?.issues ?? []) as Issue[]; + +const NO_BLOCK_VERDICT = + "This list view is `type: 'chart'` but declares no `chart` block, so it binds no dataset and there is nothing to plot."; + +describe("#22491 PUT /api/v1/meta/view refuses a `type: 'chart'` list view that binds no dataset", () => { + it('a chart view with no `chart` block: 422 INVALID_METADATA at `chart`, and nothing is stored', async () => { + const { put, storedRows } = await boot(); + const res = await put('crm_lead.pipeline_chart', chartView('crm_lead.pipeline_chart', {})); + + expect(res._status, JSON.stringify(res._json)).toBe(422); + expect(res._json?.code).toBe('INVALID_METADATA'); + expect(await storedRows('crm_lead.pipeline_chart')).toEqual([]); + const hit = issuesOf(res).find((i) => i.path === 'chart'); + expect(hit, JSON.stringify(res._json)).toBeDefined(); + expect(hit!.message!.startsWith(NO_BLOCK_VERDICT)).toBe(true); + }); + + it('an `options.chart` bag holding only `chartType`: 422 INVALID_METADATA at the bag\'s missing keys, and nothing is stored', async () => { + const { put, storedRows } = await boot(); + const res = await put( + 'crm_lead.pipeline_chart', + chartView('crm_lead.pipeline_chart', { options: { chart: { chartType: 'bar' } } }), + ); + + expect(res._status, JSON.stringify(res._json)).toBe(422); + expect(res._json?.code).toBe('INVALID_METADATA'); + expect(await storedRows('crm_lead.pipeline_chart')).toEqual([]); + const paths = issuesOf(res).map((i) => i.path); + expect(paths, JSON.stringify(res._json)).toContain('options.chart.dataset'); + expect(paths).toContain('options.chart.values'); + }); + + it('the control: a chart view that binds a dataset and a measure answers 200 and is stored with its binding', async () => { + const { put, storedRows } = await boot(); + const res = await put('crm_lead.pipeline_chart', chartView('crm_lead.pipeline_chart', { chart: BINDING })); + + expect(res._status, JSON.stringify(res._json)).toBe(200); + const rows = await storedRows('crm_lead.pipeline_chart'); + expect(rows).toHaveLength(1); + const stored = typeof rows[0].metadata === 'string' ? JSON.parse(rows[0].metadata) : rows[0].metadata; + expect(stored.chart).toEqual(BINDING); + }); + + it('a row stored before the narrowing is served as stored, flagged in `_diagnostics`, and refused on its next save', async () => { + const { engine, put, get, storedRows } = await boot(); + // Store a valid chart view through the door, then plant a copy of its + // row whose body lost the binding — the shape a row saved before this + // change can carry. The door can no longer write it, so the store is + // written directly. + expect((await put('crm_lead.pipeline_chart', chartView('crm_lead.pipeline_chart', { chart: BINDING })))._status).toBe(200); + const [row] = await storedRows('crm_lead.pipeline_chart'); + const body = typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata; + const { chart: _dropped, ...unbound } = { ...body, name: 'crm_lead.legacy_chart' }; + const { id: _id, ...rowData } = row; + await engine.insert('sys_metadata', { ...rowData, name: 'crm_lead.legacy_chart', metadata: JSON.stringify(unbound) }); + + const read = await get('crm_lead.legacy_chart'); + expect(read._status, JSON.stringify(read._json)).toBe(200); + const served = read._json?.item ?? read._json?.data ?? read._json; + expect(served.type).toBe('chart'); + expect(served).not.toHaveProperty('chart'); + expect(served._diagnostics?.valid, JSON.stringify(served._diagnostics)).toBe(false); + expect( + (served._diagnostics?.errors ?? []).some((e: Issue) => e.path === 'chart' && e.message?.startsWith(NO_BLOCK_VERDICT)), + JSON.stringify(served._diagnostics), + ).toBe(true); + + // Re-saving what was read is refused at the same path. + const { _diagnostics: _d, ...resave } = served; + const again = await put('crm_lead.legacy_chart', resave); + expect(again._status, JSON.stringify(again._json)).toBe(422); + expect(again._json?.code).toBe('INVALID_METADATA'); + expect(issuesOf(again).some((i) => i.path === 'chart')).toBe(true); + }); +}); diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index 0b28904473a..a210d9ec772 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -492,6 +492,7 @@ "checkDashboardWidgetStageOrder (function)", "checkGlobalFilterDateDefaultValue (function)", "checkListViewCalendarVisualization (function)", + "checkListViewChartBinding (function)", "checkPagePrintComposition (function)", "checkPageRequiresKind (function)", "checkPageSourceCompleteness (function)", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index 26e94e5a860..bd42aa4c820 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -477,6 +477,7 @@ "checkDashboardWidgetStageOrder": "src/ui/dashboard.zod.ts#checkDashboardWidgetStageOrder (function)", "checkGlobalFilterDateDefaultValue": "src/ui/dashboard.zod.ts#checkGlobalFilterDateDefaultValue (function)", "checkListViewCalendarVisualization": "src/ui/view.zod.ts#checkListViewCalendarVisualization (function)", + "checkListViewChartBinding": "src/ui/view.zod.ts#checkListViewChartBinding (function)", "checkPagePrintComposition": "src/ui/page.zod.ts#checkPagePrintComposition (function)", "checkPageRequiresKind": "src/ui/page.zod.ts#checkPageRequiresKind (function)", "checkPageSourceCompleteness": "src/ui/page.zod.ts#checkPageSourceCompleteness (function)", diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 3ac3991abc7..710b2aeb7f1 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -3515,6 +3515,13 @@ "toMajor": 18, "rationale": "ADR-0049 enforce-or-remove, the enforce arm: the four keys stay declared (they are the selection of every non-joined report), and the one report type that never reads them now refuses them. A `joined` report selects nothing itself, and the refinement already said so for `order` alone — it refused a container `order` with a pointer onto `blocks[]` while the four selection keys beside it parsed green. Measured at this repo's `.objectui-sha` pin `f8a9d0fb0596f4521076628e2bbfe27e6ce67d52`: `DatasetReportRenderer`'s joined branch (`DatasetReportRenderer.tsx:1462`) reads `blocks`, plus the container `runtimeFilter` and `drilldown` resolved above it, and returns before the top-level reads of `columns` / `dataset` / `rows` / `values` begin (line 1529 onward) — so each was accepted by the metadata layer and dropped by the renderer without a word. The alias tables made it reachable: `fields` / `measures` / `metrics` route to `values`, `groupings` / `groupBy` / `dimensions` to `rows`, and `objectName` / `object` / `dataSet` / `source` to `dataset`, on a joined report as on any other. Studio's report inspector hides the top-level binding for a joined report (`ReportDefaultInspector.tsx:328`) but its type picker patches only `type`, so a report bound first and switched to `joined` second carries the keys invisibly. An empty list is NOT refused: it selects nothing, which is what a joined container selects — the container `order` refusal's own threshold. Ships at once, no deprecation window: there is no window in which a key the renderer never reads does anything." }, + { + "surface": "A list view whose type is chart and whose effective chart binding names no dataset: no chart block and no options.chart bag, or, on a flattened view overlay saved through the metadata write door, an options.chart bag missing its dataset or its values while no chart block replaces it. Judged at every list-view door: views[].list and views[].listViews, objects[].listViews, a view item config, and the flattened list overlay.", + "replacement": "Bind the chart: declare a top-level `chart` block naming the ADR-0021 `dataset` to plot and at least one of its measures in `values` (`dimensions`, the X / group axis, stays optional, and `chartType` defaults to `bar`). A view whose only binding is the legacy `options.chart` bag completes the bag with `dataset` and `values`, or, preferred, moves the binding to the top-level `chart` block, which replaces the bag whole. A view that is not meant to be a chart takes another `type`.", + "migrationId": "view-chart-binding-dataset-required", + "toMajor": 18, + "rationale": "ADR-0021 single form, enforced (ADR-0049 enforce-or-remove, the enforce arm; ADR-0078, a view that renders nothing is refused rather than warned). A chart list view plots only the dataset its effective binding names, and the renderer reads that binding as the `chart` block, else the `options.chart` bag, the block replacing the bag whole (objectui plugin-list `ListView`, `resolveListChartBinding`, at this repo's `.objectui-sha` pin and at objectui main alike). The authoring `chart` block already required `dataset` and `values`, but a view with no block at all, or with only the bag, never met that schema. Measured on `origin/main` at `e148ca98`: the flattened overlay member accepted `type: 'chart'` with no block and an `options.chart` bag holding only `chartType`, and both authoring doors accepted the block-less view. What such a view rendered was a dead screen: at the pin the renderer fabricated a binding nobody wrote (an aggregate over a field named name and a measure named value), and objectui has since retired that floor, after which the chart component refuses on screen. Now refused at the view's own path, `chart`, or at `options.chart.dataset` / `options.chart.values`, with the binding to declare. Ships at once, no grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). Not convertible: only the author knows which dataset a chart was meant to show." + }, { "surface": "ui.ViewFilterRule with NO value on an operator that takes one — the value key omitted, or present and undefined, on equals, not_equals, contains, not_contains, icontains, starts_with, ends_with, greater_than, less_than, greater_than_or_equal, less_than_or_equal, before or after (an alias spelling of any of them included), on every carrier of ViewFilterRuleSchema", "replacement": "the value the rule compares against — value: \"open\" on equals, value: \"2026-01-01\" on after. A rule that meant \"the field has no value\" becomes one of the four operators that take none — is_empty / is_not_empty / is_null / is_not_null — which read their direction from their name and still parse with or without a value. A rule that was an unfinished row is deleted. The list operators (in / not_in) and the range operator (between) refused an absent value before this change and still do, in their own words", @@ -7125,6 +7132,13 @@ "toMajor": 18, "rationale": "ADR-0049 enforce-or-remove, the enforce arm: the four keys stay declared (they are the selection of every non-joined report), and the one report type that never reads them now refuses them. A `joined` report selects nothing itself, and the refinement already said so for `order` alone — it refused a container `order` with a pointer onto `blocks[]` while the four selection keys beside it parsed green. Measured at this repo's `.objectui-sha` pin `f8a9d0fb0596f4521076628e2bbfe27e6ce67d52`: `DatasetReportRenderer`'s joined branch (`DatasetReportRenderer.tsx:1462`) reads `blocks`, plus the container `runtimeFilter` and `drilldown` resolved above it, and returns before the top-level reads of `columns` / `dataset` / `rows` / `values` begin (line 1529 onward) — so each was accepted by the metadata layer and dropped by the renderer without a word. The alias tables made it reachable: `fields` / `measures` / `metrics` route to `values`, `groupings` / `groupBy` / `dimensions` to `rows`, and `objectName` / `object` / `dataSet` / `source` to `dataset`, on a joined report as on any other. Studio's report inspector hides the top-level binding for a joined report (`ReportDefaultInspector.tsx:328`) but its type picker patches only `type`, so a report bound first and switched to `joined` second carries the keys invisibly. An empty list is NOT refused: it selects nothing, which is what a joined container selects — the container `order` refusal's own threshold. Ships at once, no deprecation window: there is no window in which a key the renderer never reads does anything." }, + { + "surface": "A list view whose type is chart and whose effective chart binding names no dataset: no chart block and no options.chart bag, or, on a flattened view overlay saved through the metadata write door, an options.chart bag missing its dataset or its values while no chart block replaces it. Judged at every list-view door: views[].list and views[].listViews, objects[].listViews, a view item config, and the flattened list overlay.", + "replacement": "Bind the chart: declare a top-level `chart` block naming the ADR-0021 `dataset` to plot and at least one of its measures in `values` (`dimensions`, the X / group axis, stays optional, and `chartType` defaults to `bar`). A view whose only binding is the legacy `options.chart` bag completes the bag with `dataset` and `values`, or, preferred, moves the binding to the top-level `chart` block, which replaces the bag whole. A view that is not meant to be a chart takes another `type`.", + "migrationId": "view-chart-binding-dataset-required", + "toMajor": 18, + "rationale": "ADR-0021 single form, enforced (ADR-0049 enforce-or-remove, the enforce arm; ADR-0078, a view that renders nothing is refused rather than warned). A chart list view plots only the dataset its effective binding names, and the renderer reads that binding as the `chart` block, else the `options.chart` bag, the block replacing the bag whole (objectui plugin-list `ListView`, `resolveListChartBinding`, at this repo's `.objectui-sha` pin and at objectui main alike). The authoring `chart` block already required `dataset` and `values`, but a view with no block at all, or with only the bag, never met that schema. Measured on `origin/main` at `e148ca98`: the flattened overlay member accepted `type: 'chart'` with no block and an `options.chart` bag holding only `chartType`, and both authoring doors accepted the block-less view. What such a view rendered was a dead screen: at the pin the renderer fabricated a binding nobody wrote (an aggregate over a field named name and a measure named value), and objectui has since retired that floor, after which the chart component refuses on screen. Now refused at the view's own path, `chart`, or at `options.chart.dataset` / `options.chart.values`, with the binding to declare. Ships at once, no grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). Not convertible: only the author knows which dataset a chart was meant to show." + }, { "surface": "ui.ViewFilterRule with NO value on an operator that takes one — the value key omitted, or present and undefined, on equals, not_equals, contains, not_contains, icontains, starts_with, ends_with, greater_than, less_than, greater_than_or_equal, less_than_or_equal, before or after (an alias spelling of any of them included), on every carrier of ViewFilterRuleSchema", "replacement": "the value the rule compares against — value: \"open\" on equals, value: \"2026-01-01\" on after. A rule that meant \"the field has no value\" becomes one of the four operators that take none — is_empty / is_not_empty / is_null / is_not_null — which read their direction from their name and still parse with or without a value. A rule that was an unfinished row is deleted. The list operators (in / not_in) and the range operator (between) refused an absent value before this change and still do, in their own words", diff --git a/packages/spec/src/migrations/entries/semantic/18.view-chart-binding-dataset-required.ts b/packages/spec/src/migrations/entries/semantic/18.view-chart-binding-dataset-required.ts new file mode 100644 index 00000000000..f304406fb7f --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.view-chart-binding-dataset-required.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// The D3 entry for the list-view chart-binding check (#22491): the enforce arm +// of ADR-0049 enforce-or-remove, applied to the ADR-0021 single form the list +// chart block already required. It narrows a list view's accept set; no key is +// removed, so there is no tombstone and no RETIRED_KEYS_BY_MAJOR row. There is +// no D2 conversion either: which dataset a chart plots is the author's +// decision, and a fabricated binding is the defect this closes. +export const entry: SemanticMigration = { + id: 'view-chart-binding-dataset-required', + // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it + // inside a code span. + surface: + 'A list view whose type is chart and whose effective chart binding names no dataset: no chart block ' + + 'and no options.chart bag, or, on a flattened view overlay saved through the metadata write door, an ' + + 'options.chart bag missing its dataset or its values while no chart block replaces it. Judged at every ' + + 'list-view door: views[].list and views[].listViews, objects[].listViews, a view item config, and the ' + + 'flattened list overlay.', + replacement: + 'Bind the chart: declare a top-level `chart` block naming the ADR-0021 `dataset` to plot and at least one ' + + 'of its measures in `values` (`dimensions`, the X / group axis, stays optional, and `chartType` defaults ' + + 'to `bar`). A view whose only binding is the legacy `options.chart` bag completes the bag with `dataset` ' + + 'and `values`, or, preferred, moves the binding to the top-level `chart` block, which replaces the bag ' + + 'whole. A view that is not meant to be a chart takes another `type`.', + reason: + 'ADR-0021 single form, enforced (ADR-0049 enforce-or-remove, the enforce arm; ADR-0078, a view that ' + + 'renders nothing is refused rather than warned). A chart list view plots only the dataset its ' + + 'effective binding names, and the renderer reads that binding as the `chart` block, else the ' + + '`options.chart` bag, the block replacing the bag whole (objectui plugin-list `ListView`, ' + + '`resolveListChartBinding`, at this repo\'s `.objectui-sha` pin and at objectui main alike). The ' + + 'authoring `chart` block already required `dataset` and `values`, but a view with no block at all, or ' + + 'with only the bag, never met that schema. Measured on `origin/main` at `e148ca98`: the flattened ' + + 'overlay member accepted `type: \'chart\'` with no block and an `options.chart` bag holding only ' + + '`chartType`, and both authoring doors accepted the block-less view. What such a view rendered was a ' + + 'dead screen: at the pin the renderer fabricated a binding nobody wrote (an aggregate over a field ' + + 'named name and a measure named value), and objectui has since retired that floor, after which ' + + 'the chart component refuses on screen. Now refused at the view\'s own path, `chart`, or at ' + + '`options.chart.dataset` / `options.chart.values`, with the binding to declare. Ships at once, no ' + + 'grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). Not convertible: ' + + 'only the author knows which dataset a chart was meant to show.', + acceptanceCriteria: + 'WHICH DOOR: the spec schema\'s refusal, so it lands wherever a list view is parsed through ' + + '`@objectstack/spec` — `defineView`, `defineStack`, `os validate` / `os build`, and the metadata write ' + + 'door (`PUT /api/v1/meta/view/:name`, answering `422 INVALID_METADATA`) — as one `custom` issue at ' + + '`chart` for a view with no binding at all, or one per missing key at `options.chart.dataset` / ' + + '`options.chart.values` for an incomplete bag (overlay only; the authoring doors refuse `options` by ' + + 'name). A stored `sys_metadata` view row is neither rewritten nor refused on read: measured, the read ' + + 'door serves it as stored with the same issue in its `_diagnostics`, and it is refused on its next ' + + 'save. Fix each chart view by declaring its binding, then open it: it plots the dataset. A chart view ' + + 'that already declares a complete `chart` block parses byte-identically to before, and every view of ' + + 'another type is untouched, a grid that only offers a chart in `allowedVisualizations` included. ' + + 'Census at the time of the change: the two chart list views in `examples/app-showcase` and the chart ' + + 'list views in the `packages/lint` fixtures all bind a dataset and a measure; the only spec test that ' + + 'parsed a block-less chart view was a type-acceptance pin, re-judged in the same change.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index b9de1669e9c..26887d926b6 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -23037,6 +23037,59 @@ const step18: MigrationStep = { + 'fixture and docs example at the pin above (13 occurrences); in the cloud repo none ' + 'exist.', }, + // The D3 entry for the list-view chart-binding check (#22491): the enforce arm + // of ADR-0049 enforce-or-remove, applied to the ADR-0021 single form the list + // chart block already required. It narrows a list view's accept set; no key is + // removed, so there is no tombstone and no RETIRED_KEYS_BY_MAJOR row. There is + // no D2 conversion either: which dataset a chart plots is the author's + // decision, and a fabricated binding is the defect this closes. + { + id: 'view-chart-binding-dataset-required', + // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it + // inside a code span. + surface: + 'A list view whose type is chart and whose effective chart binding names no dataset: no chart block ' + + 'and no options.chart bag, or, on a flattened view overlay saved through the metadata write door, an ' + + 'options.chart bag missing its dataset or its values while no chart block replaces it. Judged at every ' + + 'list-view door: views[].list and views[].listViews, objects[].listViews, a view item config, and the ' + + 'flattened list overlay.', + replacement: + 'Bind the chart: declare a top-level `chart` block naming the ADR-0021 `dataset` to plot and at least one ' + + 'of its measures in `values` (`dimensions`, the X / group axis, stays optional, and `chartType` defaults ' + + 'to `bar`). A view whose only binding is the legacy `options.chart` bag completes the bag with `dataset` ' + + 'and `values`, or, preferred, moves the binding to the top-level `chart` block, which replaces the bag ' + + 'whole. A view that is not meant to be a chart takes another `type`.', + reason: + 'ADR-0021 single form, enforced (ADR-0049 enforce-or-remove, the enforce arm; ADR-0078, a view that ' + + 'renders nothing is refused rather than warned). A chart list view plots only the dataset its ' + + 'effective binding names, and the renderer reads that binding as the `chart` block, else the ' + + '`options.chart` bag, the block replacing the bag whole (objectui plugin-list `ListView`, ' + + '`resolveListChartBinding`, at this repo\'s `.objectui-sha` pin and at objectui main alike). The ' + + 'authoring `chart` block already required `dataset` and `values`, but a view with no block at all, or ' + + 'with only the bag, never met that schema. Measured on `origin/main` at `e148ca98`: the flattened ' + + 'overlay member accepted `type: \'chart\'` with no block and an `options.chart` bag holding only ' + + '`chartType`, and both authoring doors accepted the block-less view. What such a view rendered was a ' + + 'dead screen: at the pin the renderer fabricated a binding nobody wrote (an aggregate over a field ' + + 'named name and a measure named value), and objectui has since retired that floor, after which ' + + 'the chart component refuses on screen. Now refused at the view\'s own path, `chart`, or at ' + + '`options.chart.dataset` / `options.chart.values`, with the binding to declare. Ships at once, no ' + + 'grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). Not convertible: ' + + 'only the author knows which dataset a chart was meant to show.', + acceptanceCriteria: + 'WHICH DOOR: the spec schema\'s refusal, so it lands wherever a list view is parsed through ' + + '`@objectstack/spec` — `defineView`, `defineStack`, `os validate` / `os build`, and the metadata write ' + + 'door (`PUT /api/v1/meta/view/:name`, answering `422 INVALID_METADATA`) — as one `custom` issue at ' + + '`chart` for a view with no binding at all, or one per missing key at `options.chart.dataset` / ' + + '`options.chart.values` for an incomplete bag (overlay only; the authoring doors refuse `options` by ' + + 'name). A stored `sys_metadata` view row is neither rewritten nor refused on read: measured, the read ' + + 'door serves it as stored with the same issue in its `_diagnostics`, and it is refused on its next ' + + 'save. Fix each chart view by declaring its binding, then open it: it plots the dataset. A chart view ' + + 'that already declares a complete `chart` block parses byte-identically to before, and every view of ' + + 'another type is untouched, a grid that only offers a chart in `allowedVisualizations` included. ' + + 'Census at the time of the change: the two chart list views in `examples/app-showcase` and the chart ' + + 'list views in the `packages/lint` fixtures all bind a dataset and a measure; the only spec test that ' + + 'parsed a block-less chart view was a type-acceptance pin, re-judged in the same change.', + }, // The absent-value half of the coupling #6227 declared, recorded beside its // array half (`view-filter-rule-scalar-operator-array-refused`) rather than // amended onto it: that entry's own replacement prose told an upgrading author diff --git a/packages/spec/src/ui/object-refinement-check-exports.test.ts b/packages/spec/src/ui/object-refinement-check-exports.test.ts index 0ae3d47c8c6..f8536ec5078 100644 --- a/packages/spec/src/ui/object-refinement-check-exports.test.ts +++ b/packages/spec/src/ui/object-refinement-check-exports.test.ts @@ -54,6 +54,7 @@ import { ListViewSchema, ObjectListViewSchema, checkListViewCalendarVisualization, + checkListViewChartBinding, } from './view.zod'; import { PageSchema, checkPageSourceCompleteness, checkPageRequiresKind, checkPagePrintComposition } from './page.zod'; import { @@ -210,6 +211,20 @@ const calendarFixtures: Fixture[] = [ { label: 'no `appearance` at all', value: { type: 'grid', columns: ['name'] }, refusesAt: [] }, ]; +// [#22491] Shape-valid on BOTH authoring doors, so no `options` bag here (the +// two refuse it by name); the bag's paths are pinned on the overlay door in +// `view-chart-binding.test.ts`. A block missing a key is a SHAPE failure, so +// it is not a fixture of this check either. +const chartBindingFixtures: Fixture[] = [ + { label: "`type: 'chart'` with no `chart` block", value: { type: 'chart', columns: ['stage'] }, refusesAt: ['chart'] }, + { + label: "`type: 'chart'` with a block naming a dataset and a measure", + value: { type: 'chart', columns: ['stage'], chart: { dataset: 'lead_metrics', values: ['amount_sum'] } }, + refusesAt: [], + }, + { label: 'a block-less view of another type', value: { type: 'kanban', columns: ['stage'] }, refusesAt: [] }, +]; + const PAGE_BASE = { name: 'home_page', label: 'Home', type: 'home' } as const; const pageSourceFixtures: Fixture[] = [ @@ -454,6 +469,7 @@ const chartMeasureArityFixtures: Fixture[] = [ const listViewExports: ExportUnderTest[] = [ { name: 'checkListViewCalendarVisualization', check: checkListViewCalendarVisualization, fixtures: calendarFixtures }, + { name: 'checkListViewChartBinding', check: checkListViewChartBinding, fixtures: chartBindingFixtures }, ]; const MIRRORED: MirroredSchema[] = [ @@ -593,7 +609,7 @@ describe('each schema attaches its export BY IDENTIFIER — no inline copy', () const declarations = (src: string, name: string): number => src.match(new RegExp(`^\\s*(export )?function ${name}\\b`, 'gm'))?.length ?? 0; - it('view.zod.ts declares the export and chains it onto ListViewShapeSchema for ListViewSchema', () => { + it('view.zod.ts declares the exports and chains them onto ListViewShapeSchema for ListViewSchema', () => { const src = read('view.zod.ts'); expect(src).toContain('export function checkListViewCalendarVisualization('); // Exactly one declaration — the count below keys on this name. @@ -607,6 +623,14 @@ describe('each schema attaches its export BY IDENTIFIER — no inline copy', () // view.test.ts pins the behaviour; this pins that every attachment is the // export, by name, and none is an inline copy. expect(attachments(src, 'checkListViewCalendarVisualization')).toBe(3); + // [#22491] The chart-binding check: declared once, chained after the + // calendar check at the same three doors. + expect(src).toContain('export function checkListViewChartBinding('); + expect(declarations(src, 'checkListViewChartBinding')).toBe(1); + expect(src).toMatch( + /ListViewShapeSchema\s*\.superRefine\(checkListViewCalendarVisualization\)\s*\.superRefine\(checkListViewChartBinding\)/, + ); + expect(attachments(src, 'checkListViewChartBinding')).toBe(3); // [#17063] `checkListViewPageMount` was retired with the `type: 'page'` // mount it policed, so neither a declaration nor an attachment of it may // return: a re-attachment would be a check with no rule left to enforce. @@ -648,6 +672,7 @@ describe('`./index` (the `@objectstack/spec/ui` surface) exports the same functi // an enumeration of every exported refinement. it.each([ ['checkListViewCalendarVisualization', checkListViewCalendarVisualization], + ['checkListViewChartBinding', checkListViewChartBinding], ['checkPageSourceCompleteness', checkPageSourceCompleteness], ['checkPageRequiresKind', checkPageRequiresKind], ['checkPagePrintComposition', checkPagePrintComposition], @@ -661,7 +686,7 @@ describe('`./index` (the `@objectstack/spec/ui` surface) exports the same functi // [#17063] The retired member, from the same surface, in the same leg. A // downstream mirror re-attaching a check it imports from here is the whole // point of this file, so the barrel is where a relapse would first become - // reachable — the runtime namespace answers it, with the four survivors + // reachable — the runtime namespace answers it, with the survivors // above as the lit control that the namespace is really populated. it('no longer exports `checkListViewPageMount` — retired with the mount it policed', () => { expect('checkListViewPageMount' in (ui as Record)).toBe(false); diff --git a/packages/spec/src/ui/view-chart-binding.test.ts b/packages/spec/src/ui/view-chart-binding.test.ts new file mode 100644 index 00000000000..e5886840fd6 --- /dev/null +++ b/packages/spec/src/ui/view-chart-binding.test.ts @@ -0,0 +1,163 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22491] A `type: 'chart'` list view binds a dataset — at every list-view + * door, judged on the view's EFFECTIVE binding. + * + * Before (measured on `origin/main` @ `e148ca98`): the flattened overlay member + * (`PUT /api/v1/meta/view`, the Studio / MCP / AI write door) accepted + * `type: 'chart'` with no `chart` block, and accepted an `options.chart` bag + * holding only `chartType`; the two authoring doors (`ListViewSchema`, + * `ObjectListViewSchema` — what `defineStack` / `os validate` judge) accepted + * the block-less view too. Only a DECLARED `chart` block was held to its + * required `dataset` and `values`. + * + * The effective binding is the renderer's: the `chart` block, else the + * `options.chart` bag, the block replacing the bag whole — see + * `checkListViewChartBinding`'s docblock for the objectui line it mirrors. + * + * Refusal pins assert the issue `code`, its path and the message's FIRST + * sentence (the verdict); the HTTP envelope (`422 INVALID_METADATA`) is pinned + * at the real door in `packages/rest/src/meta-view-chart-binding.test.ts`. + */ + +import { describe, it, expect } from 'vitest'; +import type { z } from 'zod'; +import { + ListViewSchema, + ObjectListViewSchema, + ListChartConfigSchema, + ViewMetadataSchema, +} from './view.zod'; + +type Parse = (body: Record) => z.ZodSafeParseResult; + +const OVERLAY_IDENTITY = { name: 'crm_lead.revenue_chart', object: 'crm_lead', viewKind: 'list' } as const; + +/** The three list-view doors, the overlay through the union the write door runs. */ +const doors: ReadonlyArray = [ + ['ListViewSchema', (body) => ListViewSchema.safeParse(body)], + ['ObjectListViewSchema', (body) => ObjectListViewSchema.safeParse(body)], + ['flattened overlay (PUT /api/v1/meta/view)', (body) => ViewMetadataSchema.safeParse({ ...OVERLAY_IDENTITY, ...body })], +]; + +const overlay: Parse = (body) => ViewMetadataSchema.safeParse({ ...OVERLAY_IDENTITY, ...body }); + +const BINDING = { chartType: 'bar', dataset: 'lead_metrics', dimensions: ['stage'], values: ['amount_sum'] } as const; + +const NO_BLOCK_VERDICT = + "This list view is `type: 'chart'` but declares no `chart` block, so it binds no dataset and there is nothing to plot."; +const bagVerdict = (missing: string): string => + "This list view is `type: 'chart'` and its only chart binding is the legacy `options.chart` bag, " + + `which names no ${missing}, so there is nothing to plot.`; + +/** Every issue, nested union arms included — a shape failure on the overlay is wrapped one level down. */ +const flatten = (issues: readonly z.core.$ZodIssue[]): z.core.$ZodIssue[] => + issues.flatMap((i) => { + const nested = (i as unknown as { errors?: z.core.$ZodIssue[][] }).errors; + return i.code === 'invalid_union' && Array.isArray(nested) ? [i, ...flatten(nested.flat())] : [i]; + }); + +const issuesOf = (r: z.ZodSafeParseResult): z.core.$ZodIssue[] => + (r.success ? [] : flatten(r.error.issues)); + +const at = (r: z.ZodSafeParseResult, path: string) => + issuesOf(r).filter((i) => i.path.map(String).join('.') === path); + +const firstSentence = (message: string): string => message.slice(0, message.indexOf('. ') + 1); + +describe.each(doors)("%s — a `type: 'chart'` list view binds a dataset", (_door, parse) => { + it("REFUSES `type: 'chart'` with no `chart` block, at `chart`, naming the block's required keys", () => { + const r = parse({ type: 'chart', columns: ['stage', 'amount'] }); + expect(r.success).toBe(false); + const hits = at(r, 'chart'); + expect(hits, JSON.stringify(issuesOf(r))).toHaveLength(1); + expect(hits[0]!.code).toBe('custom'); + expect(firstSentence(hits[0]!.message)).toBe(NO_BLOCK_VERDICT); + // The remedy is the top-level block, spelled with both required keys. + expect(hits[0]!.message).toContain("`chart: { dataset: '', values: [''] }`"); + }); + + it('ACCEPTS a chart view whose `chart` block names a dataset and a measure — the lit control', () => { + const r = parse({ type: 'chart', columns: ['stage', 'amount'], chart: BINDING }); + expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); + expect((r as { data: { chart?: unknown } }).data.chart).toEqual(BINDING); + }); + + it("leaves a declared `chart` block to its own schema — `chart.dataset` / `chart.values`, and no second issue at `chart`", () => { + const r = parse({ type: 'chart', columns: ['stage'], chart: { chartType: 'line' } }); + expect(r.success).toBe(false); + expect(at(r, 'chart.dataset'), JSON.stringify(issuesOf(r))).toHaveLength(1); + expect(at(r, 'chart.values'), JSON.stringify(issuesOf(r))).toHaveLength(1); + expect(at(r, 'chart')).toEqual([]); + }); + + it('does not judge a view of another type — a block-less grid still parses', () => { + expect(parse({ type: 'grid', columns: ['name'] }).success).toBe(true); + }); + + // ⚠️ Scope: a view that only OFFERS a chart. objectui's switcher gate asks + // the same binding resolver and never offers an unbound chart, so this view + // renders as the grid it is — a degrade, not a dead screen. + it("does not judge a grid that lists 'chart' in `allowedVisualizations` without a block", () => { + const r = parse({ type: 'grid', columns: ['name'], appearance: { allowedVisualizations: ['grid', 'chart'] } }); + expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); + }); +}); + +describe("the overlay's legacy `options.chart` bag — the binding when no `chart` block replaces it", () => { + it('REFUSES a bag holding only `chartType`, at `options.chart.dataset` and `options.chart.values`', () => { + const r = overlay({ type: 'chart', columns: ['stage'], options: { chart: { chartType: 'bar' } } }); + expect(r.success).toBe(false); + const dataset = at(r, 'options.chart.dataset'); + const values = at(r, 'options.chart.values'); + expect(dataset, JSON.stringify(issuesOf(r))).toHaveLength(1); + expect(values, JSON.stringify(issuesOf(r))).toHaveLength(1); + expect(dataset[0]!.code).toBe('custom'); + expect(values[0]!.code).toBe('custom'); + expect(firstSentence(dataset[0]!.message)).toBe(bagVerdict('`dataset`')); + expect(firstSentence(values[0]!.message)).toBe(bagVerdict('measure in `values`')); + expect(dataset[0]!.message).toContain("`chart: { dataset: '', values: [''] }`"); + }); + + it('REFUSES a bag naming a dataset but no measure, at `options.chart.values` only', () => { + const r = overlay({ type: 'chart', columns: ['stage'], options: { chart: { dataset: 'lead_metrics' } } }); + expect(r.success).toBe(false); + expect(at(r, 'options.chart.values'), JSON.stringify(issuesOf(r))).toHaveLength(1); + expect(at(r, 'options.chart.dataset')).toEqual([]); + }); + + it('ACCEPTS a bag that carries the whole binding, and keeps it', () => { + const r = overlay({ type: 'chart', columns: ['stage'], options: { chart: BINDING } }); + expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); + expect((r as { data: { options?: unknown } }).data.options).toEqual({ chart: BINDING }); + }); + + it('ACCEPTS an incomplete bag under a complete `chart` block — the block replaces the bag whole', () => { + const r = overlay({ type: 'chart', columns: ['stage'], chart: BINDING, options: { chart: { chartType: 'line' } } }); + expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); + }); + + // A body that names no `type` is a PATCH on the view it shadows (the console's + // toolbar save), whose own binding decides — the overlay member reads `type` + // on the input side for exactly this line. + it('does not judge a patch that names no `type`', () => { + const r = overlay({ options: { chart: { chartType: 'line' } } }); + expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); + }); + + // The check hand-lists the binding keys for its messages. Derive the block's + // REQUIRED keys from the schema itself, so a key the block starts requiring + // that the check does not ask of the bag goes red here. + it("asks of the bag exactly what `ListChartConfigSchema` requires of the block", () => { + const shape = (ListChartConfigSchema as unknown as { shape: Record }).shape; + const required = Object.keys(shape).filter((key) => !shape[key]!.safeParse(undefined).success); + expect(required.sort()).toEqual(['dataset', 'values']); + for (const key of required) { + const bag: Record = { ...BINDING }; + delete bag[key]; + const r = overlay({ type: 'chart', columns: ['stage'], options: { chart: bag } }); + expect(at(r, `options.chart.${key}`), `${key}: ${JSON.stringify(issuesOf(r))}`).toHaveLength(1); + } + }); +}); diff --git a/packages/spec/src/ui/view-form-pagination.test.ts b/packages/spec/src/ui/view-form-pagination.test.ts index 808cfbd648e..41abdf52351 100644 --- a/packages/spec/src/ui/view-form-pagination.test.ts +++ b/packages/spec/src/ui/view-form-pagination.test.ts @@ -126,8 +126,13 @@ describe('view form — `pagination` is offered to every view type', () => { expect(offeredTo('pagination', undefined)).toHaveLength(1); }); + // A `chart` view binds a dataset (#22491), so it carries that binding here; + // every other type parses with no block of its own. + const bindingFor = (type: string) => + (type === 'chart' ? { chart: { dataset: 'lead_metrics', values: ['amount_sum'] } } : {}); + it.each(VIEW_TYPES.map((t) => [t]))("is backed by the door: type '%s' parses a pagination block and keeps it", (type) => { - const r = ListViewSchema.safeParse({ type, columns: ['name'], pagination: { pageSize: 50 } }); + const r = ListViewSchema.safeParse({ type, columns: ['name'], ...bindingFor(type), pagination: { pageSize: 50 } }); expect(r.success, JSON.stringify(r.error?.issues ?? '')).toBe(true); expect((r.data as { pagination?: unknown }).pagination).toEqual({ pageSize: 50 }); }); diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index 31fcc5e34e0..cbcafbe7ca6 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -4396,10 +4396,15 @@ describe("ListViewSchema — the RETIRED `page` view type", () => { }); it('keeps every surviving view type accepting exactly as before', () => { - const types = ['grid', 'kanban', 'gallery', 'calendar', 'timeline', 'gantt', 'map', 'chart', 'tree'] as const; + const types = ['grid', 'kanban', 'gallery', 'calendar', 'timeline', 'gantt', 'map', 'tree'] as const; for (const type of types) { expect(ListViewSchema.safeParse({ type, columns: ['name'] }).success, type).toBe(true); } + // [#22491] `chart` survives too, but a chart view binds a dataset: the + // block-less body is refused (`view-chart-binding.test.ts`), so the type + // is pinned here with the binding it now requires. + const chart = { dataset: 'lead_metrics', values: ['amount_sum'] }; + expect(ListViewSchema.safeParse({ type: 'chart', columns: ['name'], chart }).success, 'chart').toBe(true); }); describe.each(viewDoorsCarryingObjectLevelChecks)('%s', (_label, parse) => { diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index ff4843a3505..365d06a4f7f 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -2555,6 +2555,97 @@ export function checkListViewCalendarVisualization( } } +/** The remedy both chart-binding refusals close with — the top-level block, spelled out. */ +const LIST_VIEW_CHART_BINDING_REMEDY = + "Declare `chart: { dataset: '', values: [''] }`: `dataset` names the " + + 'ADR-0021 dataset to plot, `values` at least one of its measures, and `dimensions` (the X / group ' + + 'axis) is optional. There is no default binding to fall back on: the renderer plots only the names ' + + 'the author wrote.'; + +/** [#22491] `type: 'chart'` with no binding at all — refused at `chart`. */ +const LIST_VIEW_CHART_NEEDS_BINDING = + "This list view is `type: 'chart'` but declares no `chart` block, so it binds no dataset and there is " + + `nothing to plot. ${LIST_VIEW_CHART_BINDING_REMEDY} A view that is not meant to be a chart takes another \`type\`.`; + +/** + * [#22491] `type: 'chart'` whose only binding is the legacy `options.chart` + * bag, missing a key the `chart` block requires — refused at that key. + */ +const listViewChartBagMissing = (key: (typeof LIST_CHART_BINDING_KEYS)[number]): string => + "This list view is `type: 'chart'` and its only chart binding is the legacy `options.chart` bag, " + + `which names no ${key === 'dataset' ? '`dataset`' : 'measure in `values`'}, so there is nothing to plot. ` + + 'With no top-level `chart` block the bag IS the binding, so it must carry what that block requires. ' + + `${LIST_VIEW_CHART_BINDING_REMEDY} The top-level block replaces the bag whole; prefer it to completing the bag.`; + +/** + * [#22491] The keys that make a chart block a binding: the required keys of + * {@link ListChartConfigSchema} (`chartType` defaults, `dimensions` is + * optional). Hand-listed for the messages above; `view-chart-binding.test.ts` + * derives the block's required keys from the schema and fails if the two part. + */ +const LIST_CHART_BINDING_KEYS = ['dataset', 'values'] as const; + +/** + * [#22491] A `type: 'chart'` list view must bind a dataset — the view's + * EFFECTIVE chart binding names a `dataset` and at least one measure. + * + * The effective binding is read the way the renderer reads it: the top-level + * `chart` block, else the legacy `options.chart` bag, and the block replaces + * the bag WHOLE — objectui `plugin-list/src/ListView.tsx` + * `resolveListChartBinding`, `schema.chart || schema.options?.chart || {}` + * (`:207` at this repo's `.objectui-sha` pin `f0268ad7`, `:260` at objectui + * `2a48bd40`). So, unlike the merged per-key underlays + * {@link listViewKindBlocks} describes, the bag is not one layer of the chart + * block: when no block is declared it is the whole of it, and it owes what + * the block requires. + * + * - No block and no bag ⇒ one issue at `chart`. + * - No block, a bag missing `dataset` / `values` ⇒ one issue per missing key, + * at `options.chart.KEY` (the bag lives on the flattened overlay only; the + * two authoring doors refuse `options` by name). + * - A declared `chart` block ⇒ nothing here: its own strict schema already + * requires both keys, at `chart.dataset` / `chart.values`. + * + * What such a view renders is a dead screen either way: at the pin the + * renderer fabricates a binding nobody wrote (an aggregate over `'name'` / + * `'value'`); from objectui#6152 round 15 (objectui `0253416`) that floor is + * retired and `ObjectChart` refuses on screen (`chart-missing-category-axis`). + * ⛔ Never fabricate a default here either — only the author knows the dataset. + * + * Reads `type` as the door hands it: the authoring shapes apply the `grid` + * default first; the overlay member reads the input side (no default), so a + * PATCH that names no `type` is not judged — it shadows a view whose own + * binding decides. + * + * ⚠️ Scope: `type: 'chart'` only. A view of another type that merely OFFERS a + * chart (`appearance.allowedVisualizations`) is not judged: objectui's + * `availableViews` gate asks the same resolver and never offers an unbound + * chart, so that view degrades to its own type rather than rendering dead. + * + * Attached at the same three list-view doors as + * {@link checkListViewCalendarVisualization}, and exported for the same + * reason: a mirror built from `ListViewSchema.shape` re-attaches it. + */ +export function checkListViewChartBinding( + view: { type?: unknown; chart?: unknown; options?: unknown }, + ctx: z.RefinementCtx, +): void { + if (view.type !== 'chart' || view.chart !== undefined) return; + const bag = view.options !== null && typeof view.options === 'object' + ? (view.options as { chart?: unknown }).chart + : undefined; + if (bag === undefined) { + ctx.addIssue({ code: 'custom', path: ['chart'], message: LIST_VIEW_CHART_NEEDS_BINDING }); + return; + } + // A bag that is not an object is refused by the bag's own schema. + if (bag === null || typeof bag !== 'object') return; + for (const key of LIST_CHART_BINDING_KEYS) { + if ((bag as Record)[key] !== undefined) continue; + ctx.addIssue({ code: 'custom', path: ['options', 'chart', key], message: listViewChartBagMissing(key) }); + } +} + /** * List View Schema (Expanded) * Defines how a collection of records is displayed to the user. @@ -2590,8 +2681,9 @@ export function checkListViewCalendarVisualization( * containing refinements"`, thrown at construction), and * {@link ObjectListViewSchema} is built by omitting `userFilters` from this * shape. So the shape stays refinement-free and BOTH terminals attach - * {@link checkListViewCalendarVisualization} themselves — one check function, - * two attachment points, no second copy of the rule. + * {@link checkListViewCalendarVisualization} and {@link checkListViewChartBinding} + * themselves — one function per check, attached at each door, no second copy + * of either rule. * * ⛔ Not exported, deliberately: a top-level EXPORTED schema binding mints a * new protocol def in `json-schema.manifest/` and a full set of ratcheted @@ -2754,7 +2846,8 @@ const ListViewShapeSchema = lazySchema(() => strictObject({ gantt: GanttConfigSchema.optional().describe('Gantt-timeline configuration — applies when the view renders as a gantt layout'), gallery: GalleryConfigSchema.optional(), timeline: TimelineConfigSchema.optional(), - chart: ListChartConfigSchema.optional(), + chart: ListChartConfigSchema.optional() + .describe('Chart binding — applies when the view renders as a chart. A `type: \'chart\'` view must bind one: it names the ADR-0021 `dataset` and the measures (`values`) the chart plots, and there is no default binding'), map: ListMapConfigSchema.optional().describe('Map configuration — applies when the view renders as a map layout'), tree: TreeConfigSchema.optional().describe('Tree/hierarchy configuration — applies when the view renders as a tree layout'), @@ -2990,16 +3083,17 @@ const ListViewShapeSchema = lazySchema(() => strictObject({ })); /** - * List View Schema (Expanded) — {@link ListViewShapeSchema} plus the - * `allowedVisualizations` ⇄ `calendar` binding check. See that shape for why - * shape and checks are separate bindings, and - * {@link checkListViewCalendarVisualization} for what the check refuses. - * (#17063 removed the `type: 'page'` ⇄ `pageName` binding check with the mount - * it policed.) + * List View Schema (Expanded) — {@link ListViewShapeSchema} plus two binding + * checks: `allowedVisualizations` ⇄ `calendar` + * ({@link checkListViewCalendarVisualization}) and `type: 'chart'` ⇄ a dataset + * binding ({@link checkListViewChartBinding}). See that shape for why shape and + * checks are separate bindings. (#17063 removed the `type: 'page'` ⇄ + * `pageName` binding check with the mount it policed.) */ export const ListViewSchema = lazySchema(() => ListViewShapeSchema - .superRefine(checkListViewCalendarVisualization)); + .superRefine(checkListViewCalendarVisualization) + .superRefine(checkListViewChartBinding)); /** * [commit c459da6bc] Form-view select option — {@link SelectOptionSchema} minus the @@ -4739,11 +4833,12 @@ export const ObjectListViewSchema = lazySchema(() => ListViewShapeSchema.omit({ userFilters: true }) .extend({ userFilters: ObjectUserFiltersSchema.optional() }) // Derived from the UNREFINED shape (zod 4 refuses `.omit()` on a refined - // object), so the binding check is re-attached here rather than inherited. - // Dropping this line would leave `objects[].listViews.*` — the ADR-0047 + // object), so the binding checks are re-attached here rather than inherited. + // Dropping either line would leave `objects[].listViews.*` — the ADR-0047 // authoring surface — as the one door where a calendar-enabled view with - // no `calendar:` block parses clean. - .superRefine(checkListViewCalendarVisualization)); + // no `calendar:` block, or a chart view binding no dataset, parses clean. + .superRefine(checkListViewCalendarVisualization) + .superRefine(checkListViewChartBinding)); /** * [#4001/#7741] The wrap remedy, ONE prose source for two doors: the container's @@ -6221,8 +6316,10 @@ function formOverlayColumnsField(): z.ZodOptional { * `'form'` only), and it judges a column-less PATCH as well as a full inline * config — see {@link listOverlayPatchFields}. A column-less body that names a * `type` is a full config missing its columns and is refused at `columns` - * ({@link checkListOverlayTypeNeedsColumns}). The three attached checks run in + * ({@link checkListOverlayTypeNeedsColumns}). The attached checks run in * order: that refusal reads the input side, the calendar check is unchanged, + * the chart-binding check ({@link checkListViewChartBinding}, #22491) reads the + * input side too and is the one door check that sees the `options.chart` bag, * and {@link applyListOverlayTypeDefault} restores the `grid` default last. */ const ListViewOverlayWireSchema = lazySchema(() => @@ -6244,6 +6341,7 @@ const ListViewOverlayWireSchema = lazySchema(() => }).strip() .superRefine(checkListOverlayTypeNeedsColumns) .superRefine(checkListViewCalendarVisualization) + .superRefine(checkListViewChartBinding) .overwrite(applyListOverlayTypeDefault), );