From 1fc9f70452e9cb03ce5659686c24eed64f322ea9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 07:41:29 +0000 Subject: [PATCH 1/2] =?UTF-8?q?wip(spec):=20#5022=20measurement=20checkpoi?= =?UTF-8?q?nt=20=E2=80=94=20drillDown=20carrier=20evidence=20(no=20code=20?= =?UTF-8?q?yet)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 断点位置:测量完成,**尚未写任何 spec 代码**。测量结果与裁决前提冲突, 倾向 needs_decision(见下)。分支上目前是空提交,仅承载证据。 ## 已完成的测量(objectui @91757a7 + objectstack @41e605ec70) ### A. ObjectChart 实际读取词汇(反推自 packages/plugin-charts/src/ObjectChart.tsx) 读到的键(:602 起,经 @object-ui/core drill-down.ts): enabled? boolean isDrillEnabled: 无 config=关;{}=开;enabled:false=关 filter? Record computeDrillFilter,支持 ${event.*} 插值 title? string resolveDrillTitle,支持 ${event.*} 插值 target? 'drawer'|'dialog' :755 — **只分支 'dialog',其余一律 Sheet** columns? string[] :762 → accessorKey/header maxRows? number :761 → pageSize ObjectChart **不读**:mode / view / report / sort,且 **不实现 target:'navigate'** (navigate 只在 DrillDownDrawer.tsx:84 实现,ObjectChart 自绘 Sheet/Dialog,不走它)。 objectui 侧已有 TS 接口 DrillDownConfig(packages/types/src/data-display.ts:897), 是 5 个 widget 共享的更宽形状:{enabled,mode,target(含 navigate),filter,title, view,report,columns,sort,maxRows}。其中 view/sort 无任何渲染器读取。 ### B. 载体测量 —— 与裁决前提("渲染器已在读的 drillDown,objectui 零改动,挂 chart config")冲突 1. widget.chartConfig.drillDown → **无人读**。 DashboardRenderer.tsx 全文 0 处 chartConfig;DatasetWidget.tsx:595-605 只取 showLegend 一个键,注释自陈 "the rest of chartConfig stays unforwarded"; :632 构造的 chart schema 里没有 drillDown。 2. widget.options.drillDown → DashboardRenderer.tsx:494/:570/:531 确实读,但**全部在 isObjectProvider(widgetData) 的 legacy inline 分支内**;而 DashboardWidgetSchema 要求 dataset(dashboard.zod.ts:427,非 optional),datasetBound 恒真, DashboardRenderer.tsx:691/:720 恒走 ,componentSchema 被丢弃。 ⇒ 对 v17 合法 widget,options.drillDown 永不被读。 3. ADR-0021 正路 DatasetWidget 的 drill 是**从 dataset 结果派生、不可配置**的: canDrill 由 dimensionFields/drillRanges 决定,DrillDownDrawer 只收 {objectName, filter, title, dataSource} —— 上述词汇一个都不认。 4. 唯一真活的面是 **react tier**:ChartConfigSchema 的键在 react 块上是**扁平 prop** (react-blocks.ts:226-247 dataProps),所以 确实生效; 但 drillDown 既不在 spec dataProps/interactions,也不在 objectui registry inputs (ObjectChart.tsx:822-830),SDUI save gate 会报 unknown-prop(warning,不拦)。 ⇒ 结论:挂 chart config 在 **react tier 交付、dashboard 元数据面不交付**; 挂 options 在两面都不交付。任一选择都会造出 #5011 "same key, two fates" 的形状。 ## 剩余步骤(若维护者裁定后继续) 1. [待裁决] 定载体:(A) 只挂 react 块契约 /(B) 挂 ChartConfigSchema 并明写 dashboard 面不转发 /(C) 挂 options 并在 objectui DatasetWidget 落地实现(非小改动)。 2. [待裁决] 定形状:chart 诚实子集 {enabled,filter,title,target:'drawer'|'dialog', columns,maxRows} vs objectui 共享 DrillDownConfig 全集。 3. 写 ChartDrillDownSchema(strictObject,surface/history/guidance),JSDoc 与 ReportSchema.drilldown 双向点名消歧。 4. 逐键验收测试 + 调和 chart.test.ts:464 的批 15 钉子。 5. liveness/dashboard.json 分类 + check:strictness-ledger 台账行。 6. changeset(minor,v17 pre 模式);objectui 侧把 (schema as any).drillDown 换成声明类型。 7. 合 origin/main(#5154/#5162 已入队)后重跑 check:strictness-ledger + check:generated。 ## 顺带发现(超范围,待立 issue) - widget.chartConfig 在 dashboard 面只有 showLegend 落地,其余 11 个已声明键全惰性; liveness/dashboard.json 的 chartConfig 行写 "chart-config bag forwarded",高估。 - objectui DrillDownConfig.view / .sort 无渲染器读取;ObjectChart 忽略 target:'navigate'。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9 From 4bf9be2d4cfba89a8183d4f05fd8fe22e29e5dd4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 10:50:01 +0000 Subject: [PATCH 2/2] feat(spec,lint): declare the chart segment drill as ChartDrillDownSchema, on the tier that reads it (#5022) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `drillDown` drove a real capability with no protocol declaration behind it: objectui's ObjectChart read `(schema as any).drillDown` and really did open a filtered record drawer from it, while `grep drillDown packages/spec` found only a comment — the one #3752 wrote prescribing the key as a migration target. That inverted Prime Directive #10: delivered, never declared. Declared now, additively, at the one surface measured to read it. ## The shape is the six keys ObjectChart reads `{ enabled?, filter?, title?, target?: 'drawer' | 'dialog', columns?, maxRows? }`, each reverse-engineered from a read point: `isDrillEnabled` (absent = off, `{}` = on), `computeDrillFilter`, `resolveDrillTitle`, the `'dialog'` branch, the column map, the page size. objectui's own `DrillDownConfig` is wider because it is shared with the table / pivot / metric widgets, and copying that union would have promoted four keys a chart ignores — two of which NO widget reads (objectui#3354) — into protocol-declared capabilities. Each absent key is a `guidance` entry naming where it does apply, not a rename. ## The carrier is the react block, NOT chart config or the widget options bag Both candidates in the issue measured dead on the dashboard metadata path: `DashboardRenderer` never reads `chartConfig`, and `DatasetWidget` forwards exactly one key out of it (`showLegend`); `options.drillDown` is read only in `DashboardRenderer`'s legacy `isObjectProvider` branch, which a spec-legal v17 widget cannot reach, because `dataset` is required and `datasetBound` therefore discards that component schema unrendered. An ADR-0021 dataset-bound widget drills through the semantic layer and honours no drill config at all — which `content/docs/ui/dashboards.mdx` already said. So the declaration rides `react-blocks.ts`'s interaction overlay, deliberately not `ChartConfigSchema`, whose members a dashboard widget's `chartConfig` also parses. A member there would have been authorable, parse-clean and unread. The three places an author reaches for it now answer instead of shrugging: `chartConfig.drillDown` → the react prop; `widget.drillDown`/`widget.drilldown` → dashboard drill is AUTOMATIC, plus both configurable drills named; `report.drillDown` → back to the chart prop. ## Two-way disambiguation with ReportSchema.drilldown Same word, one letter apart, two capabilities. Edit distance actively gets this wrong — the spellings are distance 1, so a bare "did you mean" sends an author to the boolean slot where their config object fails a second time. Both gates name the TYPE difference, not just the spelling. ## Declared = enforced, in the same commit `.strict()` is a property of a parse, and nothing on the react surface called one. `validate-react-page-props.ts` now PARSES the schema against a static `drillDown={{…}}` literal (`react-chart-drilldown-invalid`) instead of re-deriving the rules the way `CHART_FUNCTIONS` does for `aggregate` beside it — the fix the strictness ledger's `chart.zod.ts` row has been waiting on, shown on one key. A value from React state is skipped: unresolvable is not wrong (ADR-0072 D1). ## Instruments - 批 15's pin (`chart.test.ts`) is RECONCILED, not relaxed: the ChartInteraction prescription still must not say a bare `drillDown`, now because that schema is reached from both tiers and cannot tell which, so the advice would be a dead end for half its readers. - 20 new assertions: per-key acceptance, `{}` enables, absent stays optional, unknown key rejected with the surface named, each non-chart key rejected with its reason, `target:'navigate'` prescribed (and a plain wrong value NOT given that text), the carrier verdict pinned in both directions, and the published react type string derived-checked against the schema shape. - 10 lint-gate tests; ledger row and site counts updated (`ui/` 198 → 199). Filed, not fixed here: #5175 (`chartConfig` delivers 1 of 12 keys on the dashboard path; the liveness row's evidence overstates it) and objectui#3354 (`DrillDownConfig.view`/`.sort` read by nothing; `navigate` silently degrades). Fixes #5022 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9 --- .changeset/chart-drilldown-declared.md | 96 +++++++ content/docs/references/ui/chart.mdx | 20 +- .../2026-07-unknown-key-strictness-ledger.md | 8 +- .../src/validate-react-page-props.test.ts | 92 +++++++ .../lint/src/validate-react-page-props.ts | 59 +++++ packages/spec/api-surface.json | 2 + packages/spec/authorable-surface.json | 6 + packages/spec/json-schema.manifest.json | 3 +- .../spec/scripts/strictness-ledger.test.ts | 6 +- packages/spec/src/ui/chart.test.ts | 239 +++++++++++++++++- packages/spec/src/ui/chart.zod.ts | 169 ++++++++++++- packages/spec/src/ui/dashboard.zod.ts | 20 ++ packages/spec/src/ui/react-blocks.ts | 17 ++ packages/spec/src/ui/report.zod.ts | 13 + .../contracts/react-blocks.contract.json | 7 + .../objectstack-ui/references/react-blocks.md | 1 + 16 files changed, 735 insertions(+), 23 deletions(-) create mode 100644 .changeset/chart-drilldown-declared.md diff --git a/.changeset/chart-drilldown-declared.md b/.changeset/chart-drilldown-declared.md new file mode 100644 index 0000000000..5e799fbb7f --- /dev/null +++ b/.changeset/chart-drilldown-declared.md @@ -0,0 +1,96 @@ +--- +"@objectstack/spec": minor +"@objectstack/lint": minor +--- + +feat(spec,lint): declare the chart segment drill — `ChartDrillDownSchema`, on the react tier where it is actually read (#5022) + +`drillDown` has driven a real capability since long before this release: click a +bar or a slice on an `` and objectui opens the underlying records, +filtered by the clicked category, in a drawer. The protocol declared it +**nowhere**. objectui read it as `(schema as any).drillDown`, so every key inside +it — right, wrong, or misspelled — reached the renderer unchecked, and a typo was +simply ignored at click time. This is Prime Directive #10 inverted: not declared +without being delivered, but delivered without ever being declared. + +It is declared now, as `ChartDrillDownSchema`, and it is **additive** — nothing +that parsed before stops parsing. + +## What you can write + +`drillDown` is a prop on the react-tier `` block: + +```jsx + +``` + +| key | type | meaning | +|---|---|---| +| `enabled` | `boolean` | Only needed to force the drill OFF — the block being present already means on, so `drillDown={{}}` enables it | +| `filter` | `Record` | Filter for the drilled list; values support `${event.*}`. Omit it and the filter is derived from `aggregate.groupBy` equal to the clicked category | +| `title` | `string` | Drawer/dialog heading; supports `${event.*}` | +| `target` | `'drawer' \| 'dialog'` | In-place side sheet (default), or a centered modal when the chart is already inside a drawer | +| `columns` | `string[]` | Column whitelist for the drilled list | +| `maxRows` | `number` | Rows per page in the drilled list | + +Every one of those six is a key objectui's `ObjectChart` was measured to read. +The renderer's own drill type is wider — it is shared with the table / pivot / +metric widgets — and the extra keys are **deliberately not declared**, because a +chart reads none of them: + +- **`mode`** (`'filter'`/`'record'`) is a table/pivot/metric key. A chart segment + is always an aggregate, so there is nothing to discriminate. +- **`report`** (drill into a report instead of a record list) is a metric/pivot + capability. +- **`view`** and **`sort`** are read by *no* renderer at all (objectui#3354). +- **`target: 'navigate'`** is implemented for the other widgets but not for a + chart, which falls back to the drawer. + +Writing any of them is now a loud rejection that says which surface owns it, +rather than a value that silently does nothing. + +## Where it is NOT declared, and why that is deliberate + +**Not on `ChartConfigSchema`, and not a dashboard widget key.** A dashboard +widget has no per-widget drill configuration, by design: an ADR-0021 +dataset-bound widget drills through the semantic layer, deriving the target +object and filter from the dataset row that was clicked. That is what +`content/docs/ui/dashboards.mdx` has said all along, and it is what the renderer +does — `DashboardRenderer` never reads `chartConfig`, and `DatasetWidget` +forwards exactly one key out of it (`showLegend`). Declaring the drill there +would have produced authorable metadata that parses clean and never reaches a +renderer — the failure this campaign removes elsewhere. + +So the three places an author might reach for it now answer instead of shrugging: + +- `widget.chartConfig.drillDown` → rejected, pointing at the react-tier prop. +- `widget.drillDown` / `widget.drilldown` → rejected, explaining that dashboard + drill-through is **automatic**, and naming both configurable drills. +- `report.drillDown` → rejected, pointing back at the chart prop. + +## `drillDown` is not `drilldown` + +Two capabilities, one letter apart, and they are now disambiguated in both +directions at the schema gate: + +| | `drillDown` | `drilldown` | +|---|---|---| +| spelling | camelCase | all lowercase | +| type | configuration object | boolean | +| surface | react `` prop | `ReportSchema` key (ADR-0021 D2, on by default) | + +Edit distance alone gets this wrong — the two spellings are a distance of 1, so a +plain "did you mean" would happily send an author writing `drillDown` on a report +to `drilldown`, where their config object then fails a second time as a boolean. +Both gates name the **type** difference, not just the spelling. + +## Enforced, not just declared + +`@objectstack/lint`'s react-page publish gate now **parses** the schema +(`react-chart-drilldown-invalid`) against a static `drillDown={{…}}` literal, +rather than re-deriving the rules. Unknown keys, the wrong `target`, and the +near-key spelling all fail the build with the schema's own prescription. A value +assembled from React state is skipped, unchanged: an unresolvable binding is not +a wrong one (ADR-0072 D1). diff --git a/content/docs/references/ui/chart.mdx b/content/docs/references/ui/chart.mdx index 6678a94c35..be285390e2 100644 --- a/content/docs/references/ui/chart.mdx +++ b/content/docs/references/ui/chart.mdx @@ -18,8 +18,8 @@ Provides a comprehensive set of chart types for data visualization. ## TypeScript Usage ```typescript -import { ChartAggregateSchema, ChartAggregateFunctionSchema, ChartAnnotationSchema, ChartAxisSchema, ChartConfigSchema, ChartGroupBySchema, ChartInteractionSchema, ChartSeriesSchema, ChartTypeSchema } from '@objectstack/spec/ui'; -import type { ChartAggregate, ChartAggregateFunction, ChartAnnotation, ChartAxis, ChartConfig, ChartGroupBy, ChartInteraction, ChartSeries, ChartType } from '@objectstack/spec/ui'; +import { ChartAggregateSchema, ChartAggregateFunctionSchema, ChartAnnotationSchema, ChartAxisSchema, ChartConfigSchema, ChartDrillDownSchema, ChartGroupBySchema, ChartInteractionSchema, ChartSeriesSchema, ChartTypeSchema } from '@objectstack/spec/ui'; +import type { ChartAggregate, ChartAggregateFunction, ChartAnnotation, ChartAxis, ChartConfig, ChartDrillDown, ChartGroupBy, ChartInteraction, ChartSeries, ChartType } from '@objectstack/spec/ui'; // Validate data const result = ChartAggregateSchema.parse(data); @@ -113,6 +113,22 @@ Inline aggregation for an object-bound chart | **aria** | `{ ariaLabel?: string; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | +--- + +## ChartDrillDown + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **enabled** | `boolean` | optional | Turn the segment drill on/off; the block being present already means on, so this is only needed to force it off | +| **filter** | `Record` | optional | Filter for the drilled list; values support $`{event.*}`. Omit to derive it from the clicked category | +| **title** | `string` | optional | Drill drawer/dialog heading; supports $`{event.*}` interpolation | +| **target** | `Enum<'drawer' \| 'dialog'>` | optional | Where the drilled list opens: 'drawer' (default, side sheet) or 'dialog' (centered modal) | +| **columns** | `string[]` | optional | Field names to show as columns in the drilled list (default: the table's own columns) | +| **maxRows** | `integer` | optional | Rows per page in the drilled list | + + --- ## ChartGroupBy diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index 0a53a4d52e..0af15dafdd 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -570,7 +570,7 @@ marks a provisional call made from the file's exports/JSDoc rather than a full read — verify before tightening (the #4001 "sharing-rule lesson": candidates, not verdicts). -### `ui/` — 198 sites +### `ui/` — 199 sites | File | Sites | Class | Note / next action | |---|---|---|---| @@ -583,7 +583,7 @@ not verdicts). | `dashboard.zod.ts` | 11 | authorable | **strict as of #4001 批 14 — 0 strip sites remain.** `DashboardWidgetSchema` has been strict since the ADR-0021 cutover; 批 14 closed the two NESTED holes inside it (`compareTo`'s object arm, `layout`), the same strict-shell-over-strip-children silhouette 批 13 found on `page.components[]`. `DashboardWidgetOptionsSchema` stays `passthrough` **deliberately** (renderer escape hatch) and the `responsive` tombstone (#4876) is untouched. ⚠️ **The `compareTo` union caveat this row carried is RESOLVED, and it is the one entry in this table whose limit was dissolved rather than worked around.** 批 14 recorded that `compareTo` was a UNION, so its curated prescription was produced but never delivered — `zodIssuesToFields` maps only top-level issues and a failed union collapses to a bare `Invalid input` (#5014) — with the rejection itself unaffected. **#5011 removed the union**: the slot converged onto the analytics executor's own contract, `{ kind, dimension? }`, a plain strict object whose message IS top-level. The reason was not the message, it was worse — all three declared arms were broken on the ADR-0021 dataset path (the two strings silently dropped by the renderer, `{ offset }` throwing `compareTo requires a timeDimension "undefined"`), while all three worked on the legacy inline path: same key, two fates, the failing one blessed. The union-free shape is the design benefit, pinned in `dashboard-compareto.test.ts` so it cannot silently return. **#5014 still binds every OTHER curated message this campaign has put inside a union arm** — this row is one slot's correction, not the finding's retraction | | `widget.zod.ts` | 9 | ~~authorable (p)~~ **no door** | **no authoring door (measured, #4001 批 16)** — the `(p)` resolved NEGATIVE for the whole file, the second such run after 批 13's five. Three independent measurements on 2026-08-04: (1) nothing under `packages/spec/src` imports this module except the `ui/index.ts` barrel, so no schema anywhere declares a carrier key for a widget shape — `field.widget` is a `z.string()` naming a registered *component* and has never referenced `WidgetManifest`; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack` (4 766 nodes) reaches none of the six shapes, while `PageSchema` / `ObjectListViewSchema` resolve in the same run, a fresh `z.object` and a deliberate look-alike both resolve unreachable, and a synthetic carrier flips all six to reachable; (3) zero `.parse()` / `.safeParse()` in `objectstack`, `objectui` or `cloud` outside this file's own tests — objectui re-exports the inferred TYPES only and under different names (`RuntimeWidgetManifest` / `FieldWidgetComponentProps`, #4115 / #3161), and a `cloud` code search returns 0 for every symbol against a working index (`"@objectstack/spec"` → 345). ADR-0049 enforce-or-remove is **#5055**. ⚠️ **The campaign's own BFS said REACHABLE on the first run** — a false positive in the derived-clone bridge, filed as **#5056**: zod's `.describe()` returns a clone that SHARES the original `_zod.def`, so `WidgetManifestSchema.name` / `.label` (a described `SnakeCaseIdentifierSchema` / `I18nLabelSchema`) are def-identical to the same leaves on live schemas, and a bridge firing on ANY one shared property links two unrelated shapes. 2 shared keys of 20. The error is one-directional — it can only manufacture a door, i.e. it can only make a batch tighten something dead. Corrected to whole-shape overlap in `ui/door-reachability.testkit.ts` and pinned in `widget.test.ts` | | `page.zod.ts` | 7 | authorable | partially strict (ADR-0089) | -| `chart.zod.ts` | 7 | **mixed — 5 authorable, 2 no gate** | **5 strict as of #4001 批 15**; 2 deliberately left open. `ChartConfigSchema` / `ChartAxis` / `ChartSeries` / `ChartAnnotation` / `ChartInteraction` are `root-graph`-reachable from the `dashboard` and `report` metadata roots (`DashboardWidget.chartConfig`, `ReportChartSchema`), so they are judged on the stored-metadata path and are now closed. **`ChartAggregateSchema` and `ChartGroupBySchema`'s object arm are NOT**, and this is the batch's real finding. They are not 批 13's no-door case — their carrier is LIVE: `aggregate` is a real authorable prop on the react tier's `` (ADR-0081), published in the generated react-blocks contract, and objectui's `ObjectChart` reads `schema.aggregate` to run the query. What is missing is the PARSE: neither schema is reachable from any metadata-type root or from `ObjectStackSchema` (both `UNREACHABLE` in the run where the five above come back `root-graph`), nothing in the three repos calls `.parse()` on them outside this file's unit tests, and the gate that DOES judge an authored `aggregate` — the react-page publish lint — re-derives the rules by hand (`CHART_FUNCTIONS`, the count/field requirement, the result-column naming) and never checks unknown keys. `react-blocks.ts` publishes the prop as a hand-written TYPE STRING; the Zod schema beside it is not what the contract is generated from. So `groupby` / `dateGranularty` are silently dropped today and would go on being silently dropped after a `strictObject` here — `.strict()` is a property of a parse. A fourth class, **`no gate`**: carrier live, parse absent. Distinct from `no door` (批 13), where the carrier itself does not exist. The contract-first fix is to make the publish gate PARSE the schema instead of re-deriving it — a `packages/lint` change, filed rather than smuggled into a spec strictness batch. Recorded in three places (schema-adjacent comment, test pin incl. a standing BFS assertion that goes red the day a carrier key appears, this row). ⚠️ One correction shipped with the tightening: the `clickAction` migration text #3752 wrote into this file prescribed **`drillDown`, which is not a key this protocol declares anywhere** — it is an untyped `(schema as any).drillDown` read inside objectui's `ObjectChart`. Promoting that sentence into a strict rejection would have handed an author the platform's authority for a key the same gate then rejects: finding 7, third occurrence, this time caught before shipping. The prose and the tombstone now name `onSegmentClick` / `ReportSchema.drilldown` / the widget's `options` bag, all of which exist. Filed separately. **`chart` 6 → 7 at the re-measurement** — no schema changed: `ChartAggregateSchema` is written `z\n .object({`, and the old counter's `z\.object\(` could not match across the line break | +| `chart.zod.ts` | 8 | **mixed — 6 authorable, 2 no gate** | **5 strict as of #4001 批 15**, a sixth added at **#5022**; 2 deliberately left open. `ChartConfigSchema` / `ChartAxis` / `ChartSeries` / `ChartAnnotation` / `ChartInteraction` are `root-graph`-reachable from the `dashboard` and `report` metadata roots (`DashboardWidget.chartConfig`, `ReportChartSchema`), so they are judged on the stored-metadata path and are now closed. **`ChartAggregateSchema` and `ChartGroupBySchema`'s object arm are NOT**, and this is the batch's real finding. They are not 批 13's no-door case — their carrier is LIVE: `aggregate` is a real authorable prop on the react tier's `` (ADR-0081), published in the generated react-blocks contract, and objectui's `ObjectChart` reads `schema.aggregate` to run the query. What is missing is the PARSE: neither schema is reachable from any metadata-type root or from `ObjectStackSchema` (both `UNREACHABLE` in the run where the five above come back `root-graph`), nothing in the three repos calls `.parse()` on them outside this file's unit tests, and the gate that DOES judge an authored `aggregate` — the react-page publish lint — re-derives the rules by hand (`CHART_FUNCTIONS`, the count/field requirement, the result-column naming) and never checks unknown keys. `react-blocks.ts` publishes the prop as a hand-written TYPE STRING; the Zod schema beside it is not what the contract is generated from. So `groupby` / `dateGranularty` are silently dropped today and would go on being silently dropped after a `strictObject` here — `.strict()` is a property of a parse. A fourth class, **`no gate`**: carrier live, parse absent. Distinct from `no door` (批 13), where the carrier itself does not exist. The contract-first fix is to make the publish gate PARSE the schema instead of re-deriving it — a `packages/lint` change, filed rather than smuggled into a spec strictness batch. Recorded in three places (schema-adjacent comment, test pin incl. a standing BFS assertion that goes red the day a carrier key appears, this row). ⚠️ One correction shipped with the tightening: the `clickAction` migration text #3752 wrote into this file prescribed **`drillDown`, which at the time was not a key this protocol declared anywhere** — it was an untyped `(schema as any).drillDown` read inside objectui's `ObjectChart`. Promoting that sentence into a strict rejection would have handed an author the platform's authority for a key the same gate then rejects: finding 7, third occurrence, this time caught before shipping. The prose and the tombstone now name `onSegmentClick` / `ReportSchema.drilldown` / the widget's `options` bag, all of which exist. Filed separately — and **closed at #5022**, which is the entry worth reading twice, because the fix is not the one the file's own prose implied. The gap was real (a live renderer capability with no declaration), but the two carriers that prose pointed at both measured DEAD on the dashboard metadata path: `widget.chartConfig.drillDown` is read by nothing (`DashboardRenderer` never looks at `chartConfig`; `DatasetWidget` forwards exactly one key out of it, `showLegend`), and `widget.options.drillDown` is read only inside `DashboardRenderer`'s legacy `isObjectProvider` branch, which a spec-legal v17 widget cannot reach — `dataset` is required, so `datasetBound` is always true and that component schema is discarded unrendered. An ADR-0021 dataset-bound widget drills through the semantic layer and reads no drill config at all, which the platform's own docs had already said (`content/docs/ui/dashboards.mdx`: *there is no per-widget drill configuration in the dataset form*) while this ledger row pointed authors at the `options` bag. So `drillDown` was declared as `ChartDrillDownSchema` at the ONE surface measured to read it — the react tier's `` prop, published through `react-blocks.ts`'s interaction overlay rather than through `ChartConfigSchema`, precisely so the dashboard surface does not inherit an inert key. The shape is the honest six (`enabled`/`filter`/`title`/`target`/`columns`/`maxRows`); objectui's wider renderer-side `DrillDownConfig` (`mode`/`report`/`view`/`sort`, and a `navigate` target) was NOT copied — a chart reads none of them and two are read by no widget at all (objectui#3354) — and each absent key is a `guidance` entry saying so rather than a rename. Two second-order findings came out of the same measurement and are filed, not fixed here: **#5175** (`chartConfig` delivers 1 of its 12 keys on the dashboard path, and `liveness/dashboard.json` records evidence that overstates it) and **objectui#3354**. **`chart` 6 → 7 at the re-measurement** — no schema changed: `ChartAggregateSchema` is written `z\n .object({`, and the old counter's `z\.object\(` could not match across the line break | | `i18n.zod.ts` | 6 | **split** | **`i18n` SPLITS across two classes (measured, #4001 批 16)** and is the file this table's standing warning was about. The warning said "label shapes are wide-open records by design"; measurement says something more useful. `AriaPropsSchema` is a **real door and is closed** — carried as `aria:` on ~30 live shapes under six metadata-type roots (`ListViewSchema`, `PageSchema`, `PageComponentSchema`, `DashboardWidgetSchema`, `ChartConfigSchema`, `ActionSchema`, 20 SDUI component defs) and directly BFS-reachable. It was stripping in the wild: through the `view` root, `aria: { label: 'Accounts', describedBy: 'x' }` parsed CLEAN and returned `aria: {}`, so the accessible name existed in the source file and nowhere else. The other five (`I18nObject`, `PluralRule`, `NumberFormat`, `DateFormat`, `LocaleConfig`) are **no door** — no carrier, unreachable, zero parse in all three repos; ADR-0049 is #5055. Note `NumberFormat` / `DateFormat` DO have a carrier (`LocaleConfig.numberFormat` / `.dateFormat`) but the carrier is itself doorless, so the subtree is `no door`, not `no gate`. And the warning's own subject — the wide-open **record** level — was never one of the six sites: `I18nObject.params` is a `z.record` interpolation bag whose key space is whatever the message template names, so openness there is the contract and there was nothing to close. Pinned in `i18n.zod.ts`'s header, in `i18n.test.ts`, and here | | `responsive.zod.ts` | 4 | authorable | **strict as of #4001 批 13** — all four sites (`ResponsiveConfig`, `ResponsiveStyles`, and the two per-breakpoint maps). This is the one file of batch 13's six whose `(p)` resolved POSITIVE, and it resolved on the graph rather than on the file's face: `page.components[].responsive` / `.responsiveStyles` put both shapes inside the `page` metadata-type root (`dashboard.widgets[].responsive` was the second carrier until #4876 retired it, same day). What the closure bought is the batch's whole argument in one parse — **`PageComponentSchema` has been `.strict()` since ADR-0089 D3a and that never reached these blocks**, so `{ type:'element:text', responsiveStyles: { lg: {…} }, responsive: { colums: {…}, hideOn: [] } }` parsed CLEAN and returned `responsiveStyles: {}, responsive: {}` — every styling and layout instruction the author wrote, gone, reported valid. A strict shell over strip-mode children is a closed surface's silhouette, not a closed surface. The curation is the file's real hazard rather than typos: it carries TWO breakpoint vocabularies sixteen lines apart on the same component (`responsiveStyles`' `large`/`medium`/`small`/`xsmall`, ADR-0065, against `responsive`'s Tailwind `xs`…`2xl`), so the aliases run BOTH ways between them and are anchored to the named sibling, not to edit distance — batch 12's method, and the only thing that can answer `lg` → `large`. Two entries had to be measured rather than reasoned: `{ columns: { large: 4, lg: 3 } }` used to keep HALF the map (the node laid out, at the wrong width, on breakpoints the author never named — worse than a total loss, which is at least visible); and `hideOn` → `hiddenOn` needed a hand-written alias because the distance fallback provably cannot reach it — it lowercases the input but not the candidates, so a capital in a declared key costs an extra edit against a budget of 2, and the all-lowercase `hiddenon` resolves while the correctly-cased `hideOn` does not. That asymmetry is general to camelCase keys, i.e. to most of the spec, and is filed as **#4990**. `StyleMapSchema` stays deliberately OPEN (its key space is every CSS property; objectui's `declarations()` emits whatever it is handed) — recorded in the schema JSDoc, in a test pin, and in this row | | `dataset.zod.ts` | 4 | authorable | **strict as of #4001 批 14 — 0 strip sites remain.** `DatasetSchema` was strict from the ADR-0021 cutover while the two shapes carrying the actual semantic contract — `DatasetDimension`, `DatasetMeasure` (+ `.derived`) — were not. Curated against the sibling this module's own header names, `data/analytics.zod.ts`'s Cube layer: a Cube metric's `type` IS its aggregation, so `{ name: 'revenue', type: 'sum', field: 'amount' }` parsed clean and computed a `count`; `sql` gets guidance rather than an alias, because aiming `SUM(amount)` at `field` is finding 7's trap | @@ -755,14 +755,14 @@ it the same way: the decision is also written beside the schema and pinned in a test (`flow.test.ts`, `etl.test.ts`), because a row in a table is not where the next person to open that file will look. -#### `ui/` — 76 strip of 198 +#### `ui/` — 76 strip of 199 | File | Strip | Sites | Class | Batch | |---|---|---|---|---| | `component.zod.ts` | 29 | 29 | **no gate** | ⛔ **not strictness work** — measured at 批 17 as having no parse at all: BFS-unreachable from every metadata root (all 52 targets, controls green in the same run), zero production `.parse()` sites in the three repos, and an unknown key inside `components[].properties` demonstrably survives the live `definePage()` door. The carrier (`PageComponentSchema.properties`) is live but is `z.record(z.string(), z.unknown())` — ADR-0089 D3a strictness does not recurse into it. Closing these 29 sites would gate nothing (#4583). Blocked on wiring the parse at the carrier — **#5068**. See the triage row for the full measurement | | `view.zod.ts` | 6 | 50 | mixed | **15 of 20 closed at #4001 批 18**, and a sixteenth — `ViewFilterRuleSchema`, closed by an EARLIER wave — reopened at **#5114**; the 6 that remain are each measured, and none is unfinished work. Closed: `ViewDataSchema`'s four provider arms, `UserFilterField.options`, `GanttQuickFilter.options`, `GanttConfig.tooltipFields`, `ListView.conditionalFormatting` / `.emptyState`, `FormFieldBase.keyField`, `FormView.subforms`, and `submitBehavior`'s four arms. Reachability was measured, not assumed: a BFS from all 24 metadata-type roots plus `ObjectStackSchema` resolves every one `root-graph`, with `ViewSchema`/`FormViewSchema`/`ViewItemSchema`/`PageSchema` as positive controls and 批 13's no-door shapes UNREACHABLE **in the same run** — and the instrument had to be fixed first: `lazySchema` returns a Proxy, but a carrier writes `X.optional()`, which RESOLVES it, so the closure holds the real instance and comparing the Proxy alone false-negatived `ViewDataSchema` (caught by cross-checking its two literal carrier keys, not by trusting the reading). ⚠️ **Re-checked against #5056**: every 批 18 target is `root-graph` by **identity**, so **none** of the fifteen rests on the `derived-clone` bridge that 批 16 found can mark a dead shape reachable. The one `derived-clone` verdict in the run is `ListViewSchema` — a positive CONTROL, not a target, and independently identity-reachable via `ObjectListViewSchema`. Every closed shape also has a literal carrier key in this file and a named parse door (`defineView` / `defineViewItem` / the `view` metadata-type schema / objectui's `GanttConfigSchema.safeParse` at `plugin-gantt/src/ObjectGantt.tsx:408`) — the strong-evidence class #5056 leaves standing. ⚠️ **`ListView.sort` was closed and then REVERTED, and that is the batch's most useful finding.** It carried `direction → order`, the #4721 alias for the identical tuple (`{field, direction:'desc'}` parsed to `{field, order:'asc'}` — a silently REVERSED sort). The full suite then failed one case: `view-metadata-schema.test.ts` pins `sort: [{ id, field, order }]` as the exact body a console column-sort PUT persists, and objectui stamps that `id` per row (`components/src/custom/sort-builder.tsx:68`/`:94`, `crypto.randomUUID()`). **The mechanism governs every nested block in this file and is the opposite of what the union's own comment implies: `.strip()` does NOT recurse.** `ViewMetadataSchema` rescues Studio's round-trip keys by making its flattened members `.strip()`, but that re-opens the TOP level only — a nested block closed inside `ListViewSchema` is still reached through that member, so a console-stamped key inside it becomes a 422 regardless. `id` was deliberately NOT declared to silence it: it is a React list key, and declaring it would put a UI artifact on the authorable surface and tell an AI author to emit one. The end state is #5074's authoring/wire split applied one level down; until then the shape stays open rather than half-closed against the platform's own writes. Curation on what DID close is anchored to named siblings: an option `count` gets a wrong-layer pointer to `showCount` because objectui COMPUTES it per render; and a bare `name` on the `object` data source is deliberately NOT aliased — it is a real key on the view ITEM, so a rename would be finding 7 again. `submitBehavior` became a `discriminatedUnion` on the `kind` literal it already required: as a plain union of four strict members the rejection is an `invalid_union` whose prescription #5014 measured the renderers flattening away. ⚠️ **`GanttConfigSchema` / `TreeConfigSchema` are `strictObject(…).passthrough()`** — open at the parent by design, and this ledger's own counter reads them as `strict` because `postureOf` returns early on the `strictObject` idiom without walking the chain (**#5072**); it inflates the strict count and does not affect this row's strip count. **Still open, all six measured:** `UserFiltersSchema` — closing it would 422 `allowAddTab`, which objectui's renderer reads (`plugin-list/src/UserFilters.tsx:182`/`:742`) and the spec never declared; `saveMetaItem` validates but persists the ORIGINAL body, so the stripped key still reaches the renderer and the capability WORKS today — closing removes a capability rather than making a silent failure loud (**#5073**). The 批 6e reliance question IS answered: `ObjectUserFiltersSchema` is `.omit()`ed off this base and `.omit()` inherits posture, so the pin flips from "drops" to "rejects" — that flip is wanted, and gated only on `allowAddTab`. `ViewItemSchema` ×2 — **wire, not authorable**: objectui's pin control PUTs `{...storedItem, isPinned}` (`ObjectView.tsx:882` → `data-objectstack/src/index.ts:2801`); a stored ViewItem record carries `viewKind` AND `config`, so it lands on THIS member (the flattened members are excluded by their `config: z.undefined()` guard) and closing it would 422 pinning a saved view (**#5074**). `FormFieldBaseSchema` — a module-private BASE whose sole consumer already applies `.strict()` plus the ADR-0089 `strictVisibilityError` map; the door is closed, the ledger counts the base. `ListView.sort` — reverted, see above. `ViewFilterRuleSchema` — **the same wire contamination, one block over, and it was already LIVE on `main`** (#5114): closed by an earlier wave, while objectui's filter builder stamps `id: crypto.randomUUID()` on every row it writes (`components/src/custom/filter-builder.tsx:228`, re-stamped on read-back at `plugin-view/src/config/view-config-utils.ts:146`/`:160`), and `saveMetaItem` persists the AUTHORED body verbatim — so saving a filter from the console 422'd, on all three paths including the flattened overlay that is the body actually PUT. Reopened as a p1 hotfix; `id` deliberately NOT declared, for the reason given for `sort` above. Two details worth keeping: the overlay path's rejection surfaces as `invalid_union` / *"Invalid input"* — the #5014 flattening, so the key that caused it is not in the message the author sees, which is why this sat on `main` unnoticed; and the reopening was verified in BOTH directions (re-close it and 7 assertions in `view-filter-rule-wire-id.test.ts` go red, while that file's two mechanism CONTROLS — top-level aux key rides, nested `emptyState` still rejects — stay green either way, which is what makes them controls). #5074's scope addendum names this site: its wire variant must re-open RECURSIVELY, and re-closing here is gated on that. Each verdict is recorded in three places (schema JSDoc + `view-strictness-batch18.test.ts` / `view-filter-rule-wire-id.test.ts` + this row) | | `widget.zod.ts` | 9 | 9 | **no door** | ⛔ **not strictness work** — the whole file measured unreachable from every authoring root (#4001 批 16), with no carrier key and zero parse in all three repos. ADR-0049 triage is **#5055**. See the triage row above, including why the campaign's own BFS said otherwise first (**#5056**) | -| `chart.zod.ts` | 2 | 7 | **no gate** | `ChartAggregateSchema` + `ChartGroupBySchema`'s object arm. Config / axis / series / annotation / interaction closed at 批 15; these two are NOT unfinished work — their carrier (``) is live but nothing parses them, so closing them would gate nothing (#4583). Blocked on wiring the react-page publish gate to parse the schema instead of re-deriving it — see the triage row | +| `chart.zod.ts` | 2 | 8 | **no gate** | `ChartAggregateSchema` + `ChartGroupBySchema`'s object arm. Config / axis / series / annotation / interaction closed at 批 15; these two are NOT unfinished work — their carrier (``) is live but nothing parses them, so closing them would gate nothing (#4583). Blocked on wiring the react-page publish gate to parse the schema instead of re-deriving it — see the triage row. **7 → 8 at #5022**: `ChartDrillDownSchema` is a NEW strict site, and the first one in this file to arrive with its gate already wired — `validate-react-page-props.ts` parses it against a static `drillDown={{…}}` literal instead of re-deriving the rules the way `CHART_FUNCTIONS` does for `aggregate` beside it. That is the fix this row has been waiting on, demonstrated on one key; the two open sites are unchanged because their prop is `aggregate`, not `drillDown` | | `touch.zod.ts` | 7 | 7 | **no door** | ⛔ **not strictness work** — measured unreachable from every authoring root (#4001 批 13); ADR-0049 triage is #4988. See the triage row above | | `i18n.zod.ts` | 5 | 6 | **split** | **批 16 closed the one real door**: `AriaPropsSchema` (`strictObject`, carried as `aria:` on ~30 shapes under six metadata-type roots — it was returning `aria: {}` for a legacy-spelled block). The 5 left are `I18nObject` / `PluralRule` / `NumberFormat` / `DateFormat` / `LocaleConfig`, all **no door** (#5055) — ⛔ **do not close them**. This row shrinks without disappearing, the third such in the ledger after `flow` (批 11) and `etl` (批 12): the reverse pin fires on ZERO, so a row parked at a deliberate floor looks exactly like a row nobody finished, and only the `Class` column separates them | | `animation.zod.ts` | 4 | 4 | **no door** | ⛔ same as `touch` — #4988 | diff --git a/packages/lint/src/validate-react-page-props.test.ts b/packages/lint/src/validate-react-page-props.test.ts index 8efcca9f90..2e48a76d23 100644 --- a/packages/lint/src/validate-react-page-props.test.ts +++ b/packages/lint/src/validate-react-page-props.test.ts @@ -5,6 +5,7 @@ import { REACT_CHART_FIELD_UNKNOWN, REACT_CHART_AGGREGATE_INVALID, REACT_CHART_AXIS_UNKNOWN, + REACT_CHART_DRILLDOWN_INVALID, REACT_BLOCK_NEEDS_RECORD_CONTEXT, type ReactPropFinding as PropFinding, } from './validate-react-page-props.js'; @@ -771,3 +772,94 @@ describe('validateReactPageProps — resolve per child obj expect(f).toEqual([]); }); }); + +// ───────────────────────────────────────────────────────────────────────── +// — the declared shape, ENFORCED (#5022) +// +// This is the half that makes `ChartDrillDownSchema` more than a type. The +// schema is `.strict()`, but `.strict()` is a property of a PARSE — before +// this gate nothing on the react surface called one, which is exactly the +// `no gate` verdict the strictness ledger records for `aggregate` two props +// over. The rule parses instead of re-deriving, so the surface name, the +// near-key guidance and the `target: 'navigate'` prescription all arrive +// without being restated here. +// ───────────────────────────────────────────────────────────────────────── + +describe('validateReactPageProps — (#5022)', () => { + const drill = (cfg: string) => + chartPage(chart(`objectName="invoice" aggregate={{ field: 'total', function: 'sum', groupBy: 'status' }} drillDown={${cfg}}`)); + + it('passes a fully-declared drill config', () => { + const f = validateReactPageProps( + drill(`{ enabled: true, target: 'dialog', columns: ['status'], maxRows: 25, title: 'Deals' }`), + ); + expect(f).toEqual([]); + }); + + it('passes the empty config — presence alone enables the drill', () => { + expect(validateReactPageProps(drill('{}'))).toEqual([]); + }); + + it('passes a chart with no drillDown at all', () => { + const f = validateReactPageProps( + chartPage(chart(`objectName="invoice" aggregate={{ field: 'total', function: 'sum', groupBy: 'status' }}`)), + ); + expect(f).toEqual([]); + }); + + it('rejects an undeclared key inside the block, carrying the schema’s own suggestion', () => { + const f = validateReactPageProps(drill(`{ maxrows: 10 }`)); + const hit = f.find((x) => x.rule === REACT_CHART_DRILLDOWN_INVALID); + expect(hit, 'the unknown key must be reported').toBeTruthy(); + expect(hit!.message).toContain('chart drill-down block'); + expect(hit!.message, 'the schema’s suggester reaches the author through the gate').toContain('`maxrows` → `maxRows`'); + expect(hit!.severity).toBe('error'); + }); + + it("rejects target: 'navigate' with the chart-specific reason", () => { + const f = validateReactPageProps(drill(`{ target: 'navigate' }`)); + const hit = f.find((x) => x.rule === REACT_CHART_DRILLDOWN_INVALID); + expect(hit!.message).toContain('objectui#3354'); + expect(hit!.message).toContain('drillDown.target'); + }); + + it('rejects a key that belongs to another widget, with the reason rather than a rename', () => { + const f = validateReactPageProps(drill(`{ mode: 'record' }`)); + const hit = f.find((x) => x.rule === REACT_CHART_DRILLDOWN_INVALID); + expect(hit!.message).toContain('TABLE / PIVOT / METRIC'); + }); + + it('rejects the report near-key spelling and names the type difference', () => { + const f = validateReactPageProps(drill(`{ drilldown: true }`)); + const hit = f.find((x) => x.rule === REACT_CHART_DRILLDOWN_INVALID); + expect(hit!.message).toContain('ReportSchema.drilldown'); + expect(hit!.message).toContain('BOOLEAN'); + }); + + it('rejects a non-object drillDown and shows the two shapes that work', () => { + const f = validateReactPageProps(drill('true')); + const hit = f.find((x) => x.rule === REACT_CHART_DRILLDOWN_INVALID); + expect(hit!.message).toContain('configuration object'); + expect(hit!.hint).toContain('drillDown={{}}'); + }); + + it('says nothing about a config it cannot resolve — unresolvable is not wrong (ADR-0072 D1)', () => { + // A value assembled from React state. `staticValue` collapses the whole + // object, so the gate must stay silent rather than guess at the keys. + const f = validateReactPageProps( + chartPage( + `function Page(){ const cols=useCols(); return ; }`, + ), + ); + expect(f.filter((x) => x.rule === REACT_CHART_DRILLDOWN_INVALID)).toEqual([]); + }); + + it('checks the drill even on an inline-data chart, which skips every aggregate rule', () => { + // The drill config is independent of how the chart is bound; the rule runs + // before the `data` early return for exactly this case. + const f = validateReactPageProps( + chartPage(`function Page(){ return ; }`), + ); + expect(f.some((x) => x.rule === REACT_CHART_DRILLDOWN_INVALID)).toBe(true); + }); +}); diff --git a/packages/lint/src/validate-react-page-props.ts b/packages/lint/src/validate-react-page-props.ts index 602d34e328..8193830042 100644 --- a/packages/lint/src/validate-react-page-props.ts +++ b/packages/lint/src/validate-react-page-props.ts @@ -39,6 +39,7 @@ import { REACT_BLOCKS, RECORD_CONTEXT_BLOCK_TAGS, REACT_RECORD_BLOCK_ALTERNATIVES, + ChartDrillDownSchema, chartAggregateResultKeys, isRecordContextBlockType, } from '@objectstack/spec/ui'; @@ -254,6 +255,48 @@ function filterAttrValue(tsc: typeof ts, sf: ts.SourceFile, attr: ts.JsxAttribut export const REACT_CHART_FIELD_UNKNOWN = 'react-chart-field-unknown'; export const REACT_CHART_AGGREGATE_INVALID = 'react-chart-aggregate-invalid'; export const REACT_CHART_AXIS_UNKNOWN = 'react-chart-axis-unknown'; +export const REACT_CHART_DRILLDOWN_INVALID = 'react-chart-drilldown-invalid'; + +/** + * `` against `ChartDrillDownSchema` (#5022). + * + * The block's segment drill is the one react-tier prop whose whole VALUE is a + * declared protocol shape, so this is the gate that makes `declared = enforced` + * true for it: without a parse, a strict schema is just a type — `.strict()` is + * a property of a parse, and nothing else on this surface calls one. + * + * Only a fully static object literal is judged. `staticValue` collapses an + * object to `NOT_STATIC` when ANY member is unresolvable, which is the right + * conservatism here: a `filter` built from React state makes the surrounding + * keys unknowable too, and an unresolvable binding is not a wrong one + * (ADR-0072 D1). + */ +function checkChartDrillDown( + raw: unknown, + push: (severity: ReactPropSeverity, rule: string, message: string, hint: string) => void, +): void { + if (raw === undefined || raw === NOT_STATIC) return; + if (!isRec(raw)) { + push( + 'error', + REACT_CHART_DRILLDOWN_INVALID, + `drillDown must be a configuration object, not ${Array.isArray(raw) ? 'an array' : typeof raw}.`, + "Write drillDown={{ … }} — or, to turn the drill on with all defaults, drillDown={{}}. Omit the prop entirely to leave drill off.", + ); + return; + } + const parsed = ChartDrillDownSchema.safeParse(raw); + if (parsed.success) return; + for (const issue of parsed.error.issues) { + const at = issue.path.length ? `drillDown.${issue.path.join('.')}` : 'drillDown'; + push( + 'error', + REACT_CHART_DRILLDOWN_INVALID, + `${at}: ${issue.message}`, + 'The drill config is declared by ChartDrillDownSchema (@objectstack/spec/ui) — the rejection above carries the fix.', + ); + } +} const CHART_FUNCTIONS = ['count', 'sum', 'avg', 'min', 'max'] as const; @@ -279,6 +322,22 @@ function checkObjectChart( const push = (severity: ReactPropSeverity, rule: string, message: string, hint: string) => findings.push({ severity, rule, where, path, message, hint }); + // 0. `drillDown` — checked by PARSING the schema, not by re-deriving it. + // + // Runs before every early return below because the drill config is + // independent of how the chart is bound: an inline `data={…}` chart drills + // just as an aggregate-bound one does. + // + // This is the one rule in this file that does not restate its schema's + // vocabulary in local constants. `CHART_FUNCTIONS` above is the alternative, + // and the strictness ledger's `chart.zod.ts` row already names it as the + // weakness: a gate that re-derives the rules cannot inherit the schema's + // unknown-key handling, so `groupby` sails through it. Parsing inherits all + // of it for free — the surface name, the near-key guidance, the + // `target: 'navigate'` prescription — which is why #5022 declared the shape + // as Zod rather than as another list here. + checkChartDrillDown(values.get('drillDown'), push); + // Inline `data` wins over the aggregate query: the columns then come from // the author's own rows, which this rule cannot see. Nothing further to say. if (values.has('data')) return; diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index d503abae2d..4ac9a61da8 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -3195,6 +3195,8 @@ "ChartAxisSchema (const)", "ChartConfig (type)", "ChartConfigSchema (const)", + "ChartDrillDown (type)", + "ChartDrillDownSchema (const)", "ChartGroupBy (type)", "ChartGroupBySchema (const)", "ChartInteraction (type)", diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index 37111d2e1a..6e43ed8235 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -7050,6 +7050,12 @@ "ui/ChartConfig:type", "ui/ChartConfig:xAxis", "ui/ChartConfig:yAxis", + "ui/ChartDrillDown:columns", + "ui/ChartDrillDown:enabled", + "ui/ChartDrillDown:filter", + "ui/ChartDrillDown:maxRows", + "ui/ChartDrillDown:target", + "ui/ChartDrillDown:title", "ui/ChartInteraction:brush", "ui/ChartInteraction:tooltips", "ui/ChartSeries:color", diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index 68ac65360f..b3a1946f84 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -1,5 +1,5 @@ { - "description": "Ratchet manifest of every JSON Schema emitted by scripts/build-schemas.ts. Auto-appended when new schemas are added (commit the change). A listed schema that a build no longer emits fails gen:schema \u2014 remove a key ONLY for a deliberate retirement. See #2978.", + "description": "Ratchet manifest of every JSON Schema emitted by scripts/build-schemas.ts. Auto-appended when new schemas are added (commit the change). A listed schema that a build no longer emits fails gen:schema — remove a key ONLY for a deliberate retirement. See #2978.", "schemas": [ "ai/AIModelConfig", "ai/AIUsageRecord", @@ -1482,6 +1482,7 @@ "ui/ChartAnnotation", "ui/ChartAxis", "ui/ChartConfig", + "ui/ChartDrillDown", "ui/ChartGroupBy", "ui/ChartInteraction", "ui/ChartSeries", diff --git a/packages/spec/scripts/strictness-ledger.test.ts b/packages/spec/scripts/strictness-ledger.test.ts index 1627b10543..b8ddc93d61 100644 --- a/packages/spec/scripts/strictness-ledger.test.ts +++ b/packages/spec/scripts/strictness-ledger.test.ts @@ -99,8 +99,12 @@ describe('site counting reads the AST, not the source text', () => { it('counts a call the source wraps across lines (`z\\n .object({`)', () => { // ChartAggregateSchema is written wrapped; the old `z\.object\(` missed it. + // 7 → 8 at #5022, which ADDED `ChartDrillDownSchema` (a strictObject site). + // The count is incidental to what this case actually pins — that a wrapped + // call is seen at all — so the assertion that carries the meaning is the + // one below it, not this number. const chart = analyzeSites(at('ui/chart.zod.ts')); - expect(chart).toHaveLength(7); + expect(chart).toHaveLength(8); expect(chart.map((s) => s.name)).toContain('ChartAggregateSchema'); // The one that was hidden ENTIRELY: a wrapped call was this file's only diff --git a/packages/spec/src/ui/chart.test.ts b/packages/spec/src/ui/chart.test.ts index 9f8ff71f89..da5242ba16 100644 --- a/packages/spec/src/ui/chart.test.ts +++ b/packages/spec/src/ui/chart.test.ts @@ -8,10 +8,12 @@ import { ChartInteractionSchema, ChartAggregateSchema, ChartGroupBySchema, + ChartDrillDownSchema, type ChartType, type ChartConfig, } from './chart.zod'; -import { ReportChartSchema } from './report.zod'; +import { ReportChartSchema, ReportSchema } from './report.zod'; +import { REACT_BLOCKS } from './react-blocks'; import { getMetadataTypeSchema, listMetadataTypeSchemaTypes } from '../kernel/metadata-type-schemas'; import { ObjectStackSchema } from '../stack.zod'; @@ -461,18 +463,40 @@ describe('#4001 批 15 — the five closed chart sites', () => { expect(both.split('• ').length - 1).toBe(2); }); - it('never prescribes `drillDown` — it is not a key this protocol declares', () => { - // The #3752 migration prose said "Migration: `drillDown`" until 批 15. - // There is no `drillDown` anywhere in the spec; it is an untyped - // `(schema as any).drillDown` read inside objectui's ObjectChart. Promoting - // that sentence into a rejection message would have handed an author the - // platform's authority for a key the same gate then rejects — the ledger's - // finding 7, a third time. Filed separately, corrected here. + it('never prescribes a BARE `drillDown` — the key exists now, but not on this surface', () => { + // RECONCILED with #5022, not relaxed. + // + // 批 15 pinned this because the #3752 prose said "Migration: `drillDown`" + // for a key the protocol declared nowhere — prescribing it would have lent + // an author the platform's authority for a key the same gate then rejects + // (finding 7, third occurrence). #5022 declared the key, so the ORIGINAL + // reason is gone. The pin survives on a second, independent one: + // + // `ChartInteractionSchema` is reached from BOTH tiers — a dashboard + // widget's `chartConfig.interaction` and the react `` block — + // and cannot tell which. `drillDown` is a REACT-TIER prop only; a dataset- + // bound dashboard widget drills through the semantic layer and reads no + // drill config at all. So a bare prescription here would be sound advice + // for half its readers and a dead end for the other half. That is the same + // finding wearing a different hat, so the assertion is unchanged and its + // justification is what moved. Surface-qualified prescriptions live where + // the surface IS knowable — see the two tests below. const msg = reject(ChartInteractionSchema as never, { clickAction: 'x' }); expect(msg).not.toContain('drillDown'); expect(msg, 'it must name something that exists instead').toContain('drilldown'); }); + it('a `drillDown` written INSIDE the chart config is rejected, and told which surface owns it', () => { + // The chart-config level DOES know its surface, so here the prescription is + // allowed — and required. Without it, #5022's declaration would make + // `widget.chartConfig.drillDown` look plausible to the next author. + const msg = reject(ChartConfigSchema as never, { type: 'bar', drillDown: { enabled: true } }); + expect(msg).toContain('ObjectChart'); + expect(msg, 'names the react tier as the owner').toContain('REACT-TIER'); + expect(msg, 'and says what a dashboard does instead').toContain('semantic layer'); + expect(msg, 'and disambiguates the report near-key').toContain('drilldown'); + }); + it('points wrong-layer keys at the layer that owns them, naming a real key', () => { const width = reject(ChartConfigSchema as never, { type: 'bar', width: 400 }); expect(width).toContain('layout.w'); @@ -541,3 +565,202 @@ describe('#4001 批 15 — the two chart sites deliberately LEFT OPEN (measured, expect(reachable(ChartGroupBySchema), 'a carrier key would make this reachable — re-read chart.zod.ts').toBe(false); }); }); + +// ============================================================================ +// #5022 — `ChartDrillDownSchema`: a live renderer capability, finally declared. +// +// The gap 批 15 found and filed: objectui's `ObjectChart` read +// `(schema as any).drillDown` and really did drive a drill drawer from it, +// while the protocol declared the key nowhere. Declared here at the surface +// that measurably reads it — the REACT TIER — and nowhere else, because the +// dashboard metadata path reads no drill config at all. +// +// The per-key tests below are the acceptance criteria in schema form: every +// declared key is one an `ObjectChart.tsx` read point was measured for, and +// nothing else got in. +// ============================================================================ +describe('#5022 — ChartDrillDownSchema', () => { + const reject = (value: unknown): string => { + const r = ChartDrillDownSchema.safeParse(value); + expect(r.success, 'expected this to be REJECTED').toBe(false); + return JSON.stringify(r.error?.issues ?? []); + }; + + it('declares exactly the six keys ObjectChart was measured to read — no more', () => { + // The honest subset. objectui's renderer-side `DrillDownConfig` is wider + // (`mode` / `report` / `view` / `sort`, and a `navigate` target) because it + // is shared with the table / pivot / metric widgets. A chart reads none of + // those, so copying the union would have promoted four keys a chart ignores + // — two of which NO widget reads (objectui#3354) — into protocol-declared + // capabilities. This assertion is what stops the next sweep "completing" + // the shape from the objectui type. + const shape = Object.keys( + (ChartDrillDownSchema as unknown as { _zod: { def: { shape: Record } } })._zod.def.shape, + ); + expect(shape.sort()).toEqual(['columns', 'enabled', 'filter', 'maxRows', 'target', 'title']); + }); + + it.each([ + ['enabled', { enabled: false }], + ['filter', { filter: { stage: '${event.category}' } }], + ['title', { title: '${event.categoryLabel} deals' }], + ['target drawer', { target: 'drawer' }], + ['target dialog', { target: 'dialog' }], + ['columns', { columns: ['name', 'amount'] }], + ['maxRows', { maxRows: 50 }], + ['everything at once', { + enabled: true, + filter: { stage: '${event.category}' }, + title: 'Deals', + target: 'dialog' as const, + columns: ['name'], + maxRows: 25, + }], + ])('accepts %s', (_name, value) => { + expect(ChartDrillDownSchema.safeParse(value).success).toBe(true); + }); + + it('`{}` is a valid config — the block being present is what turns drill on', () => { + // `isDrillEnabled` in objectui reads `config.enabled !== false`, so an + // empty object enables. A schema that demanded `enabled` would contradict + // the renderer it was reverse-engineered from. + expect(ChartDrillDownSchema.safeParse({}).success).toBe(true); + }); + + it('stays OPTIONAL — a chart with no drillDown is the norm, not an omission', () => { + // Absence means drill OFF. Nothing may require this block. + expect(ChartDrillDownSchema.optional().safeParse(undefined).success).toBe(true); + }); + + it('rejects an unknown key inside the block, naming the surface and suggesting the near one', () => { + const msg = reject({ enabled: true, maxrows: 10 }); + expect(msg, 'the surface is named').toContain('chart drill-down block'); + expect(msg, 'and the offending key echoed').toContain('maxrows'); + expect(msg, 'and the closest declared key offered').toContain('`maxrows` → `maxRows`'); + }); + + it.each([ + ['limit', 'maxRows'], + ['pageSize', 'maxRows'], + ['fields', 'columns'], + ['where', 'filter'], + ['openIn', 'target'], + ])('renames the renderer/SQL vocabulary an author brings: %s → %s', (wrong, right) => { + expect(reject({ [wrong]: 1 })).toContain(`\`${wrong}\` → \`${right}\``); + }); + + it.each([ + ['mode', 'TABLE / PIVOT / METRIC'], + ['report', 'METRIC / PIVOT'], + ['view', 'objectui#3354'], + ['sort', 'objectui#3354'], + ])('`%s` is rejected with the reason it is absent, not a rename', (key, expected) => { + // Each of these is REAL somewhere — on another widget, or (view/sort) in a + // renderer type that nothing reads. Edit distance would have proposed a + // rename to an unrelated key; a `guidance` entry suppresses that and says + // what the key actually is. + const msg = reject({ [key]: key === 'sort' ? [] : 'x' }); + expect(msg).toContain(expected); + expect(msg, 'a guidance entry suppresses the rename suggestion').not.toContain(`\`${key}\` → `); + }); + + it("target: 'navigate' is rejected with the reason a CHART cannot honor it", () => { + // The one arm of objectui's shared `target` union that ObjectChart does not + // implement: it falls through to the Sheet, so declaring it would promise a + // jump that never happens. A bare enum error would say only "invalid + // option" and leave the author to discover that by clicking. + const msg = reject({ target: 'navigate' }); + expect(msg).toContain('objectui#3354'); + expect(msg, 'and points at the arms that do work').toContain("'dialog'"); + expect(msg, 'and at the affordance that replaces it').toContain('Open in list'); + }); + + it('a plain wrong VALUE still gets zod\'s own message — the navigate text is not sprayed over everything', () => { + // The `previosPeriod` lesson from #5011: a targeted prescription must not + // fire for every wrong input, or it misinforms. + const msg = reject({ target: 'sidebar' }); + expect(msg).not.toContain('objectui#3354'); + }); + + // ---- the near-key, both directions (the 2026-08-04 ruling, item 3) ------- + it('`drilldown` (lowercase) on the CHART points at the report boolean', () => { + const msg = reject({ drilldown: true }); + expect(msg).toContain('ReportSchema.drilldown'); + expect(msg, 'names the type difference, not just the spelling').toContain('BOOLEAN'); + expect(msg).toContain('capital D'); + }); + + it('`drillDown` (camelCase) on the REPORT points back at the chart prop', () => { + // The direction edit distance actively gets WRONG: `drillDown` → + // `drilldown` is a distance of 1, so without this the suggester proposes + // the rename and the author writes a config object into a boolean slot. + const r = ReportSchema.safeParse({ + name: 'r_one', label: 'R', type: 'summary', dataset: 'ds', + drillDown: { target: 'dialog' }, + }); + expect(r.success).toBe(false); + const msg = JSON.stringify(r.error?.issues ?? []); + expect(msg).toContain('ObjectChart'); + expect(msg, 'names the type difference in this direction too').toContain('BOOLEAN'); + expect(msg, 'and the rename suggestion must NOT be what the author sees').not.toContain('`drillDown` → `drilldown`'); + }); + + // ---- the carrier verdict, pinned --------------------------------------- + it('is NOT a member of ChartConfigSchema — the dashboard path would not deliver it', () => { + // The whole point of the #5022 placement. `ChartConfigSchema` is what a + // dashboard widget's `chartConfig` parses, and objectui's DatasetWidget + // forwards exactly one key out of it (`showLegend`). A member here would be + // authorable, parse clean, and never reach a renderer. + const shape = Object.keys( + (ChartConfigSchema as unknown as { _zod: { def: { shape: Record } } })._zod.def.shape, + ); + expect(shape).not.toContain('drillDown'); + }); + + it('is NOT a dashboard widget key either, and the widget says why', () => { + const dash = getMetadataTypeSchema('dashboard'); + const widget = (extra: Record) => ({ + name: 'dash_one', label: 'D', + widgets: [{ id: 'w1', type: 'bar', title: 'W', dataset: 'ds', dimensions: ['a'], values: ['b'], ...extra }], + }); + expect(dash!.safeParse(widget({})).success, 'control').toBe(true); + const r = dash!.safeParse(widget({ drillDown: { enabled: true } })); + expect(r.success).toBe(false); + const msg = JSON.stringify(r.error?.issues ?? []); + expect(msg, 'the capability is automatic, not missing').toContain('AUTOMATIC'); + expect(msg, 'and names where each configurable drill lives').toContain('ChartDrillDownSchema'); + expect(msg).toContain('ReportSchema.drilldown'); + // The lowercase spelling on a widget lands on the same prescription — an + // author who half-remembers the report key must not get a bare + // "unrecognized key" and conclude the feature is gone. + expect(JSON.stringify(dash!.safeParse(widget({ drilldown: true })).error?.issues ?? [])).toContain('AUTOMATIC'); + }); + + it('is published on the ObjectChart REACT block, and the type string matches the schema', () => { + // `react-blocks.ts` publishes overlay props as hand-written type strings + // (the ledger names that as a weakness). This is the pin that keeps the + // string from becoming a second source of truth: every declared key must + // appear in it, and no key it names may be undeclared. + const chart = REACT_BLOCKS.find((b) => b.tag === 'ObjectChart')!; + const drill = chart.interactions.find((i) => i.name === 'drillDown'); + expect(drill, 'the block must publish the prop at all').toBeTruthy(); + const shape = Object.keys( + (ChartDrillDownSchema as unknown as { _zod: { def: { shape: Record } } })._zod.def.shape, + ); + for (const key of shape) { + expect(drill!.type, `published type must name ${key}`).toContain(`${key}?:`); + } + // ...and nothing beyond the shape (the drift that matters — a type string + // advertising `mode` while the schema rejects it). + const named = [...drill!.type.matchAll(/(\w+)\?:/g)].map((m) => m[1]); + expect(named.sort()).toEqual([...shape].sort()); + }); + + it('is NOT published as a dataProp — that channel is the dashboard-shared schema', () => { + // If a later change moves it into `ChartConfigSchema` and lists it here, + // the dashboard surface silently gains an inert key. Two assertions, one + // per half, so either half regressing is caught. + const chart = REACT_BLOCKS.find((b) => b.tag === 'ObjectChart')!; + expect(chart.dataProps ?? []).not.toContain('drillDown'); + }); +}); diff --git a/packages/spec/src/ui/chart.zod.ts b/packages/spec/src/ui/chart.zod.ts index dd6e85cb3b..d97843bace 100644 --- a/packages/spec/src/ui/chart.zod.ts +++ b/packages/spec/src/ui/chart.zod.ts @@ -310,13 +310,23 @@ export const ChartAnnotationSchema = lazySchema(() => strictObject( // form: not the file's FIRST block, but internal prose inside a published one). // // That `clickAction` paragraph read "Migration: `drillDown`" from #3752 until -// #4001 批 15, and **`drillDown` is not a key this protocol declares -// anywhere** — it is an untyped `(schema as any).drillDown` read inside -// objectui's `ObjectChart`. Promoting that sentence into the strict rejection -// message below would have handed an author the platform's authority for a key -// the very same gate then rejects: the ledger's finding 7, third occurrence. -// The underlying gap — a live renderer capability with no spec declaration — -// is filed, not fixed here. +// #4001 批 15, when `drillDown` was not a key this protocol declared anywhere — +// it was an untyped `(schema as any).drillDown` read inside objectui's +// `ObjectChart`. Promoting that sentence into the strict rejection message +// below would have handed an author the platform's authority for a key the very +// same gate then rejects: the ledger's finding 7, third occurrence. +// +// #5022 closed the gap — see `ChartDrillDownSchema` below — but the +// prescription here STILL must not read "Migration: `drillDown`", and the pin +// in `chart.test.ts` still holds. The reason changed, it did not go away: the +// declared key is a REACT-TIER PROP (``), and this +// schema is reached from BOTH tiers — `chartConfig.interaction` on a dashboard +// widget and the react block — with no way to tell which one the author is on. +// A bare `drillDown` here would be right for half its readers and inert advice +// for the other half, which is finding 7 wearing a different hat. The +// surface-qualified prescription lives where the surface IS knowable: on +// `ChartConfigSchema`'s `guidance` (chart-config level) and on the dashboard +// widget's strict error (`dashboard.zod.ts`). export const ChartInteractionSchema = lazySchema(() => strictObject( { surface: 'this chart interaction block', @@ -341,6 +351,143 @@ export const ChartInteractionSchema = lazySchema(() => strictObject( }, )); +/** + * Chart segment drill-down — the `` REACT-TIER prop. + * + * Clicking a bar / slice / point opens the underlying records, filtered by the + * click context, in a side drawer (default) or a dialog. Absent means OFF; an + * empty object `{}` is enough to turn it on. + * + * ```jsx + * + * ``` + * + * ## Which surface this is for + * + * This is the react tier only (ADR-0081) — the surface where a chart's config + * props ARE the flat props the renderer reads. On a DASHBOARD widget there is + * no per-widget drill configuration at all: an ADR-0021 dataset-bound widget + * drills through the semantic layer, deriving the drill target and filter from + * the dataset row that was clicked, and honours none of the keys below. That is + * why `drillDown` is deliberately NOT a member of `ChartConfigSchema` — writing + * it inside a widget's `chartConfig` is rejected, with a pointer, rather than + * accepted-and-ignored (#5022). + * + * ## Not to be confused with `ReportSchema.drilldown` + * + * Same word, three differences, and they are two unrelated capabilities: + * + * - **spelling** — `drillDown` (camelCase) here; `drilldown` (all lowercase) on a report. + * - **type** — a configuration OBJECT here; a plain BOOLEAN on a report. + * - **surface** — a react `` prop here; a top-level key on + * `ReportSchema` there (ADR-0021 D2, on by default, switching row/cell click + * drill on or off for a `summary`/`matrix` report). + * + * ## Keys that belong to other widgets, not to a chart + * + * objectui's renderer carries a wider drill config shared by its table / pivot / + * metric widgets (`mode`, `report`, and a `target: 'navigate'` arm). A chart + * reads none of them, so they are not declared here — see the `guidance` + * entries, which name each one and where it does apply. + */ +export const ChartDrillDownSchema = lazySchema(() => strictObject( + { + surface: 'this chart drill-down block', + history: + 'Until #5022 `drillDown` was not declared anywhere in this protocol at all — objectui\'s ObjectChart read it as an untyped `(schema as any).drillDown`, so every key inside it, right or wrong, reached the renderer unchecked and a misspelling was simply ignored at click time.', + aliases: { + enable: 'enabled', on: 'enabled', active: 'enabled', + where: 'filter', criteria: 'filter', filters: 'filter', + label: 'title', heading: 'title', drawerTitle: 'title', + display: 'target', open: 'target', openIn: 'target', presentation: 'target', + fields: 'columns', columnList: 'columns', select: 'columns', + limit: 'maxRows', pageSize: 'maxRows', rowLimit: 'maxRows', max: 'maxRows', + }, + guidance: { + // The near-key. Bidirectional: `ReportSchema`'s own strict gate carries + // the mirror of this sentence for an author who writes `drillDown` there. + drilldown: + '`drilldown` (all lowercase) is a different capability on a different surface: it is `ReportSchema.drilldown`, a BOOLEAN that switches row/cell drill on a `summary`/`matrix` report (ADR-0021 D2, on by default). The chart\'s drill-down is `drillDown` (camelCase) and takes a configuration OBJECT. If you meant the chart, fix the capital D; if you meant the report, move the key to the report and write `true`/`false`.', + // Keys of objectui's wider renderer-side drill config. Each is real — + // on another widget — so a rename suggestion would be actively wrong. + mode: + '`mode` (`\'filter\'` | `\'record\'`) is a TABLE / PIVOT / METRIC drill key, not a chart one: it chooses whether a click drills through an aggregate to a filtered list or straight to one record. A chart segment is always an aggregate, so a chart drill is always the filtered-list kind and there is nothing to discriminate. Delete the key.', + report: + '`report` (drill into an analytical report instead of the record list) is a METRIC / PIVOT widget capability in the objectui renderer; `` does not read it and renders the record list regardless. Delete the key, or drill from a metric widget instead.', + view: + '`view` (render a named list view inside the drill drawer) is declared in objectui\'s renderer-side type as reserved and is read by no renderer at all (objectui#3354). It has never done anything — delete it and use `columns` to choose what the drill list shows.', + sort: + '`sort` (default ordering for the drill list) is declared in objectui\'s renderer-side type and read by no renderer (objectui#3354). Delete it; the drill list uses the object\'s own default ordering.', + }, + }, + { + /** + * Master switch. OMITTING the whole `drillDown` block is what turns drill + * off; once the block is present the default is ON, so `{}` enables it and + * only an explicit `enabled: false` disables it again (which is what you + * want when the block carries `columns`/`maxRows` you are toggling around). + */ + enabled: z.boolean().optional() + .describe('Turn the segment drill on/off; the block being present already means on, so this is only needed to force it off'), + + /** + * Filter applied to the drilled record list. Every value supports + * `${event.*}` interpolation against the click payload — a chart click + * exposes `category` (the raw grouped value), `categoryLabel` (its display + * label), `series` and `value`. + * + * OMIT IT for the common case: with no `filter` the drill derives one from + * the chart's own grouping (`aggregate.groupBy`, else the x-axis field) + * equal to the clicked category, which is what a segment click means. + */ + filter: z.record(z.string(), z.unknown()).optional() + .describe('Filter for the drilled list; values support ${event.*}. Omit to derive it from the clicked category'), + + /** + * Drawer/dialog heading. Supports `${event.*}` interpolation + * (e.g. `'${event.categoryLabel} deals'`). Falls back to the clicked + * category label, then to the chart's own title. + */ + title: z.string().optional() + .describe('Drill drawer/dialog heading; supports ${event.*} interpolation'), + + /** + * Where the drilled list is rendered. `'drawer'` (default) is an in-place + * side sheet; `'dialog'` is a centered modal, for when the chart is already + * inside a drawer and a second sheet would stack badly. + * + * There is no `'navigate'` arm here even though objectui's shared renderer + * type has one: `` does not implement it and silently renders + * the drawer instead (objectui#3354). Escalating to the object's full list + * page is available anyway, and needs no config — the drill drawer shows an + * "Open in list" action whenever the host app provides drill navigation. + */ + target: z.enum(['drawer', 'dialog'], { + error: (issue) => + issue.code === 'invalid_value' && issue.input === 'navigate' + ? "`drillDown.target: 'navigate'` is not supported by a chart. objectui's shared drill type offers it for the table/pivot/metric widgets, but `` does not implement that arm — it renders the drawer regardless (objectui#3354), so declaring it here would promise a jump that never happens. Use 'drawer' (the default) or 'dialog'; the drawer already offers an \"Open in list\" action when the host app wires drill navigation." + : undefined, + }).optional() + .describe("Where the drilled list opens: 'drawer' (default, side sheet) or 'dialog' (centered modal)"), + + /** + * Whitelist of field names shown as columns in the drilled list, in order. + * Omit to let the record table pick its default columns. + */ + columns: z.array(z.string()).optional() + .describe('Field names to show as columns in the drilled list (default: the table\'s own columns)'), + + /** + * Page size for the drilled list. The list is paginated, so this caps what + * one page fetches rather than truncating the result set. + */ + maxRows: z.number().int().positive().optional() + .describe('Rows per page in the drilled list'), + }, +)); + /** * Chart Configuration Base * Common configuration for all chart types @@ -387,6 +534,13 @@ export const ChartConfigSchema = lazySchema(() => strictObject( '`axes` is not a key — the two axes are declared separately and asymmetrically: `xAxis` is a single axis, `yAxis` is an ARRAY (that is how dual-axis and combo charts are configured).', options: '`options` is not part of the chart config — renderer-only presentation extras belong in the dashboard widget\'s `options` bag, which is deliberately open for exactly that.', + // #5022. The one key on this list that IS declared elsewhere in this very + // file, so the pointer has to name the SURFACE, not just the key — the + // #4410 lesson. `drillDown` is real (ChartDrillDownSchema) and it is a + // react-tier PROP; inside a dashboard widget's `chartConfig` it would be + // parsed-and-ignored, which is the silence this campaign exists to kill. + drillDown: + '`drillDown` is not part of the chart config — it is a REACT-TIER prop, written beside `objectName`/`aggregate`: ``. On a DASHBOARD widget there is no per-widget drill configuration at all: an ADR-0021 dataset-bound widget drills through the semantic layer (the drill target and filter come from the clicked dataset row), so a `drillDown` written here would parse and then do nothing. On a REPORT the switch is `drilldown` — all lowercase, and a boolean.', }, }, { @@ -568,3 +722,4 @@ export type ChartAxis = z.infer; export type ChartSeries = z.infer; export type ChartAnnotation = z.infer; export type ChartInteraction = z.infer; +export type ChartDrillDown = z.infer; diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts index d0ade7658e..de40a9e3c5 100644 --- a/packages/spec/src/ui/dashboard.zod.ts +++ b/packages/spec/src/ui/dashboard.zod.ts @@ -158,6 +158,26 @@ const strictWidgetAnalyticsError: z.core.$ZodErrorMap = (issue) => { 'not part of the author-facing dashboard spec (framework#3251).' ); } + // #5022 — the drill near-key, in all three spellings an author reaches for. + // A dashboard widget has NO per-widget drill configuration, by design: an + // ADR-0021 dataset-bound widget drills through the semantic layer, deriving + // the target object and filter from the clicked dataset row. Saying only + // "unrecognized key" here would leave the author to conclude the capability + // is missing, when in fact it is automatic — and would leave them guessing + // between two real keys on two other surfaces. + if (keys.some((k) => k === 'drillDown' || k === 'drilldown' || k === 'drill')) { + return ( + base + + ' Drill-through on a dashboard is AUTOMATIC and not configurable per widget: ' + + 'a dataset-bound widget derives the drill target and filter from the dataset row ' + + 'that was clicked, and a `table`/`pivot` widget is the one to reach for when you ' + + 'want the detail to be clickable (`metric`/`chart` render the aggregate only). ' + + 'The two configurable drills live elsewhere and neither is a widget key: ' + + '`drillDown` (camelCase, a config object) is the react-tier `` ' + + 'prop — `ChartDrillDownSchema`; `drilldown` (all lowercase, a boolean) is ' + + '`ReportSchema.drilldown` (ADR-0021 D2, on by default).' + ); + } return base; }; diff --git a/packages/spec/src/ui/react-blocks.ts b/packages/spec/src/ui/react-blocks.ts index 24a7e8f00e..4695263af8 100644 --- a/packages/spec/src/ui/react-blocks.ts +++ b/packages/spec/src/ui/react-blocks.ts @@ -244,6 +244,23 @@ export const REACT_BLOCKS: ReactBlockDef[] = [ { name: 'filter', type: 'FilterArray', kind: 'controlled', description: 'ObjectQL filter scoping the data; drive from React state.' }, { name: 'aggregate', type: "{ field?: string; function: 'count' | 'sum' | 'avg' | 'min' | 'max'; groupBy: string | { field: string; dateGranularity?: 'day' | 'week' | 'month' | 'quarter' | 'year' } }", kind: 'binding', description: 'Aggregation run against objectName. Result rows are keyed by the RAW FIELD NAMES: one column named after groupBy (the category) and one named after field (the value; the literal "count" for a fieldless count). Bind xAxis.field / yAxis[].field / series[].name to those names.' }, { name: 'data', type: 'any[]', kind: 'binding', description: 'Static/precomputed data to chart directly instead of binding via objectName + aggregate.' }, + // #5022 — the segment drill. Published from the OVERLAY, not from + // `dataProps`, and that placement is the whole point of the issue. + // + // `dataProps` reads `block.schema`, which is `ChartConfigSchema`, which is + // also what a DASHBOARD widget's `chartConfig` parses. Declaring the drill + // there would make `widget.chartConfig.drillDown` legal metadata that no + // dashboard renderer reads (objectui `DatasetWidget` forwards exactly one + // chartConfig key, `showLegend`) — the declared-but-not-delivered shape + // this campaign removes elsewhere. The overlay publishes it on the react + // tier ONLY, which is the tier that measurably reads it. + // + // The type string is not a second source of truth: `react-blocks.test.ts` + // derives the key set from `ChartDrillDownSchema` and fails if this string + // drifts from it. `renderType` would flatten the nested object to the + // useless `'object'` anyway, which is why `aggregate` above is spelled out + // the same way. + { name: 'drillDown', type: "{ enabled?: boolean; filter?: Record; title?: string; target?: 'drawer' | 'dialog'; columns?: string[]; maxRows?: number }", kind: 'binding', description: "Click a segment to open the underlying records, filtered by the clicked category, in a drawer (or 'dialog'). Present = on; {} is enough. `filter`/`title` support ${event.*} interpolation; omit `filter` to derive it from aggregate.groupBy. Declared by ChartDrillDownSchema — NOT a dashboard widget key (a dataset-bound widget drills through the semantic layer instead), and not ReportSchema.drilldown (that is lowercase, boolean, report-only)." }, ], }, // NOTE: `` / `` / `` / diff --git a/packages/spec/src/ui/report.zod.ts b/packages/spec/src/ui/report.zod.ts index 321ed1e4c4..a959090922 100644 --- a/packages/spec/src/ui/report.zod.ts +++ b/packages/spec/src/ui/report.zod.ts @@ -250,6 +250,19 @@ export const ReportSchema = lazySchema(() => strictObject({ history: 'Until #4001 closed this shape these were dropped silently — the item still registered, minus whatever the key was meant to configure.', aliases: { dataSet: 'dataset', source: 'dataset', fields: 'values', columns: 'values', chart: 'chartConfig', filter: 'filters' }, + guidance: { + // #5022 — the reverse half of a two-way disambiguation. The forward half + // lives on `ChartDrillDownSchema` in `ui/chart.zod.ts`, which tells an + // author who writes `drilldown` on a CHART that they want the report's + // boolean. This one catches the opposite mistake, and it is the one edit + // distance cannot help with: `drillDown` → `drilldown` is a distance of 1, + // so the suggester would cheerfully propose the rename and the author would + // write `drilldown: { target: 'dialog' }` — a config object into a boolean + // slot, rejected a second time. Naming the TYPE difference is what makes + // the difference actionable (批 10's lesson, applied to a case-only twin). + drillDown: + '`drillDown` (camelCase) is a different capability on a different surface: it is the react-tier `` prop, a configuration OBJECT (`ChartDrillDownSchema` — `enabled`/`filter`/`title`/`target`/`columns`/`maxRows`) that configures a CHART segment drill. A report\'s drill switch is `drilldown` — all lowercase, and a plain BOOLEAN (ADR-0021 D2, on by default), which turns row/cell drill on a `summary`/`matrix` report on or off. If you meant this report, write `drilldown: true` or `drilldown: false`; if you meant a chart, the prop belongs on a react page, not in report metadata.', + }, }, { /** Identity */ name: SnakeCaseIdentifierSchema.describe('Report unique name'), diff --git a/skills/objectstack-ui/contracts/react-blocks.contract.json b/skills/objectstack-ui/contracts/react-blocks.contract.json index 615ad6c271..0445227600 100644 --- a/skills/objectstack-ui/contracts/react-blocks.contract.json +++ b/skills/objectstack-ui/contracts/react-blocks.contract.json @@ -313,6 +313,13 @@ "required": false, "description": "Static/precomputed data to chart directly instead of binding via objectName + aggregate." }, + { + "name": "drillDown", + "type": "{ enabled?: boolean; filter?: Record; title?: string; target?: 'drawer' | 'dialog'; columns?: string[]; maxRows?: number }", + "kind": "binding", + "required": false, + "description": "Click a segment to open the underlying records, filtered by the clicked category, in a drawer (or 'dialog'). Present = on; {} is enough. `filter`/`title` support ${event.*} interpolation; omit `filter` to derive it from aggregate.groupBy. Declared by ChartDrillDownSchema — NOT a dashboard widget key (a dataset-bound widget drills through the semantic layer instead), and not ReportSchema.drilldown (that is lowercase, boolean, report-only)." + }, { "name": "filter", "type": "FilterArray", diff --git a/skills/objectstack-ui/references/react-blocks.md b/skills/objectstack-ui/references/react-blocks.md index d3d706a9b1..d6ae7e1427 100644 --- a/skills/objectstack-ui/references/react-blocks.md +++ b/skills/objectstack-ui/references/react-blocks.md @@ -72,6 +72,7 @@ Chart over an object’s aggregated data. Bind objectName + aggregate; the axes | `objectName` | `string` | binding | ✓ | The object this block binds to (server-connected). | | `aggregate` | `{ field?: string; function: 'count' \| 'sum' \| 'avg' \| 'min' \| 'max'; groupBy: string \| { field: string; dateGranularity?: 'day' \| 'week' \| 'month' \| 'quarter' \| 'year' } }` | binding | | Aggregation run against objectName. Result rows are keyed by the RAW FIELD NAMES: one column named after groupBy (the category) and one named after field (the value; the literal "count" for a fieldless count). Bind xAxis.field / yAxis[].field / series[].name to those names. | | `data` | `any[]` | binding | | Static/precomputed data to chart directly instead of binding via objectName + aggregate. | +| `drillDown` | `{ enabled?: boolean; filter?: Record; title?: string; target?: 'drawer' \| 'dialog'; columns?: string[]; maxRows?: number }` | binding | | Click a segment to open the underlying records, filtered by the clicked category, in a drawer (or 'dialog'). Present = on; {} is enough. `filter`/`title` support ${event.*} interpolation; omit `filter` to derive it from aggregate.groupBy. Declared by ChartDrillDownSchema — NOT a dashboard widget key (a dataset-bound widget drills through the semantic layer instead), and not ReportSchema.drilldown (that is lowercase, boolean, report-only). | | `filter` | `FilterArray` | controlled | | ObjectQL filter scoping the data; drive from React state. | | `type` | `'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| 'funnel' \| 'scatter' \| 'treemap' \| 'sankey' \| 'combo' \| 'gauge' \| 'solid-gauge' \| 'metric' \| 'kpi' \| 'bullet' \| 'radar' \| 'table' \| 'pivot'` | data | ✓ | | | `title` | `string` | data | | Chart title |