From 70ea5b79b524cf809b2dec43624287aa156f6de0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 04:08:45 +0000 Subject: [PATCH] docs(plugin-dashboard): README's chart example taught a rejected `type: 'card'` widget (objectui#7035) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Dashboard with Charts" example put two widgets spelled `type: 'card'` with a nested `body` into `widgets[]`. `'card'` is not a member of `DashboardWidgetTypeName` — the vocabulary objectui#4600 closed — so `@object-ui/types/zod`'s `DashboardComponentSchema.safeParse` refuses the whole document. Measured on the shipped contract, not read off the type: the block returned `invalid_union` at `widgets[1]` and `widgets[2]`. Rendered unvalidated, the first one's `body` names `line-chart`, which nothing registers, so it paints the registry's OBJUI-001 panel. Both are now the chart-family widgets the renderer actually dispatches — `line` and `pie`, with their inline rows under `options.data` and `options.xField` / `options.yField` — plus one paragraph stating the rule the example now follows. The new test parses every dashboard example on the page through the shipped schema. No gate asked that question before: `check:doc-snippets` compiles fenced blocks and these are untyped literals, `check:doc-types` covers `content/docs/**` and the root README only, and `check:readme-exports` judges imports. Verified by reverting the block: the test goes red on exactly those two widgets. The three `type: 'metric-card'` examples are deliberately untouched — the 2026-08-14 maintainer ruling (objectstack#8593) admits that component node into the widget slot, and both the Zod union and the renderer accept it as written. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013uAaxiwgYDybsTNV9xwa1M --- .../dashboard-readme-card-widget-7035.md | 14 ++ packages/plugin-dashboard/README.md | 26 ++-- ...adme-dashboard-examples-spec-valid.test.ts | 143 ++++++++++++++++++ 3 files changed, 174 insertions(+), 9 deletions(-) create mode 100644 .changeset/dashboard-readme-card-widget-7035.md create mode 100644 packages/plugin-dashboard/src/__tests__/readme-dashboard-examples-spec-valid.test.ts diff --git a/.changeset/dashboard-readme-card-widget-7035.md b/.changeset/dashboard-readme-card-widget-7035.md new file mode 100644 index 0000000000..b80bb90ba3 --- /dev/null +++ b/.changeset/dashboard-readme-card-widget-7035.md @@ -0,0 +1,14 @@ +--- +--- + +Docs + test only, releases nothing. + +`packages/plugin-dashboard/README.md`'s "Dashboard with Charts" example taught two +widgets spelled `type: 'card'` with a nested `body`. `'card'` is not a member of the +widget vocabulary objectui#4600 closed, so the whole document was refused by +`@object-ui/types/zod`'s `DashboardComponentSchema` — measured, not assumed. They are +now the chart-family widgets the renderer actually dispatches (`line` / `pie` with +inline rows under `options.data`), and a new test parses every dashboard example on +that page through the shipped schema so the shape cannot come back unnoticed. + +No package source changed; the new file is a test. diff --git a/packages/plugin-dashboard/README.md b/packages/plugin-dashboard/README.md index ca47432f85..99358fbbd3 100644 --- a/packages/plugin-dashboard/README.md +++ b/packages/plugin-dashboard/README.md @@ -223,26 +223,34 @@ const schema = { value: '$123,456' }, { - type: 'card', + type: 'line', title: 'Sales Trend', - body: { - type: 'line-chart', - data: [/* chart data */], - height: 300 + options: { + data: [/* [{ name: 'Jan', value: 1200 }, …] */], + xField: 'name', + yField: 'value' } }, { - type: 'card', + type: 'pie', title: 'Category Distribution', - body: { - type: 'pie-chart', - data: [/* chart data */] + options: { + data: [/* [{ name: 'Hardware', value: 40 }, …] */], + xField: 'name', + yField: 'value' } } ] }; ``` +A chart widget names its family in `type` — one of the spec's chart families, +the closed vocabulary `DashboardWidgetTypeName` declares — and carries its +inline rows under `options.data`, with `options.xField` / `options.yField` +naming the category and value keys. There is no `card` widget family and no +nested `body` slot: a widget whose `type` is outside that vocabulary is refused +at validation, by `@object-ui/types/zod`'s `DashboardComponentSchema`. + ### Responsive Dashboard ```typescript diff --git a/packages/plugin-dashboard/src/__tests__/readme-dashboard-examples-spec-valid.test.ts b/packages/plugin-dashboard/src/__tests__/readme-dashboard-examples-spec-valid.test.ts new file mode 100644 index 0000000000..79b939ecda --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/readme-dashboard-examples-spec-valid.test.ts @@ -0,0 +1,143 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Every dashboard example on this package's front page must survive the SHIPPED + * contract — `@object-ui/types/zod`'s `DashboardComponentSchema`, the same + * schema `objectui validate` runs. + * + * ## The hole this closes (objectui#7035) + * + * The README taught two widgets spelled `type: 'card'` with a nested `body`. + * `'card'` is not a member of `DashboardWidgetTypeName` — the vocabulary + * objectui#4600 closed — so `DashboardComponentSchema.safeParse` refused the + * whole document, and a reader who copied the block got a rejection. Three + * gates were green on it the whole time, each for a stated reason: + * + * - `check:doc-snippets` compiles fenced `ts` against the built `dist/`, and + * these blocks are untyped `const schema = { … }` literals, so nothing was + * annotated for tsc to refuse. Its own header names schema-key validity as + * the question it does NOT answer. + * - `check:doc-types` asks whether a `type` literal names a REGISTERED + * component, and its surface is `content/docs/**` plus the root README — + * package READMEs are outside it. (`card` is registered anyway; what + * rejected the widget was the widget vocabulary, not the registry.) + * - `check:readme-exports` judges import bindings, not metadata literals. + * + * So the one question nobody was asking is the one this file asks: does the + * example still validate. It is deliberately narrow — this document only. + * + * ## Why the blocks are evaluated rather than parsed as JSON + * + * They are TypeScript object literals with comments and trailing prose, not + * JSON. Evaluating them is what keeps this test reading the BYTES a reader + * copies instead of a hand-maintained twin that drifts from the page. + * + * A block this harness cannot evaluate FAILS — it is never skipped. An + * unexaminable block that reads as a clean one is the failure shape the doc + * gate family exists to prevent (objectui#4846), and the fix when a new block + * needs an ambient name is to add it to `AMBIENT` below, deliberately. + * + * `plugin-gantt`'s `readme-navigation-example.test.ts` is the same idea one + * package over — its README's `json` fence against the shipped navigation + * schema — so this is that pattern applied here, not a new one. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { DashboardComponentSchema } from '@object-ui/types/zod'; + +const README = resolve(dirname(fileURLToPath(import.meta.url)), '../../README.md'); + +/** + * Names a block uses without declaring — supplied so the literal can be + * evaluated. Values are inert stand-ins: this test asks about the SHAPE the + * page teaches, not about what an adapter returns. + */ +const AMBIENT: Record = { + createObjectStackAdapter: () => ({ kind: 'stub-datasource' }), +}; + +interface Block { + heading: string; + /** 1-based line of the block's first code line, for a failure message that points at the page. */ + line: number; + source: string; +} + +function dashboardBlocks(markdown: string): Block[] { + const lines = markdown.split('\n'); + const blocks: Block[] = []; + let heading = '(top)'; + for (let i = 0; i < lines.length; i++) { + if (lines[i].startsWith('#')) heading = lines[i].replace(/^#+\s*/, ''); + if (!lines[i].startsWith('```')) continue; + const end = lines.indexOf('```', i + 1); + if (end === -1) break; + const source = lines.slice(i + 1, end).join('\n'); + if (/const\s+schema\b/.test(source) && /type:\s*'dashboard'/.test(source)) { + blocks.push({ heading, line: i + 2, source }); + } + i = end; + } + return blocks; +} + +/** Strip the TypeScript a bare `new Function` cannot read, leaving the literal. */ +function toEvaluable(source: string): string { + return source + .split('\n') + .filter((l) => !/^\s*import\b/.test(l)) + .join('\n') + // `declare const widgets: DashboardWidgetSchema[];` — a declared-elsewhere + // array the block spreads into `widgets`. + .replace(/^\s*declare\s+const\s+(\w+)\s*:[^=;]*;/gm, 'const $1 = [];') + // `const schema: DashboardComponentSchema = {` — drop the annotation. + .replace(/^(\s*const\s+\w+)\s*:\s*[\w<>[\]| ]+\s*=/gm, '$1 ='); +} + +const blocks = dashboardBlocks(readFileSync(README, 'utf8')); + +describe('plugin-dashboard README dashboard examples', () => { + // A silent zero would make every assertion below vacuous — the extractor + // failing is indistinguishable from the page being clean without this. + it('finds the dashboard examples to judge', () => { + expect(blocks.length).toBeGreaterThanOrEqual(6); + }); + + it.each(blocks.map((b) => [`:${b.line} ${b.heading}`, b] as const))( + '%s validates against the shipped DashboardComponentSchema', + (_label, block) => { + // Not wrapped in a try/catch: the evaluator's own error names the + // offending identifier and is thrown from this line, and the test's own + // name already carries the README line and heading — which is louder + // than anything a re-throw could add. (A wrapper would also have to + // attach the caught error as a `cause` to satisfy `preserve-caught-error`, + // and `Error.cause` is ES2022 — above this project's ES2020 lib. Same + // reasoning, same route as `plugin-gantt`'s README example test.) + // + // A block that throws here therefore FAILS. That is the point: the fix is + // to add the name it needs to `AMBIENT`, deliberately. + const doc: unknown = new Function( + ...Object.keys(AMBIENT), + `${toEvaluable(block.source)}\n; return schema;`, + )(...Object.values(AMBIENT)); + + const result = DashboardComponentSchema.safeParse(doc); + const issues = result.success + ? '' + : result.error.issues + .map((i) => `${i.code} at widgets${i.path.slice(1).join('.')}: ${i.message}`) + .join('; '); + expect(issues, `README.md:${block.line} ("${block.heading}") is refused`).toBe(''); + expect(result.success).toBe(true); + }, + ); +});