diff --git a/.changeset/dashboard-declare-honoured-inputs.md b/.changeset/dashboard-declare-honoured-inputs.md new file mode 100644 index 0000000000..c0129d95e5 --- /dev/null +++ b/.changeset/dashboard-declare-honoured-inputs.md @@ -0,0 +1,5 @@ +--- +'@object-ui/plugin-dashboard': minor +--- + +`dashboard` now publishes the authoring inputs its renderer already honours — `widgets`, `label`, `description`, `header`, `globalFilters`, `dateRange`, `refreshInterval` — so `validateTree`, the generated `sdui.manifest.json` and `sdui-intrinsics.d.ts` stop warning authors off keys that work (previously only `columns`/`gap`/`className` were published, and every other honoured key drew `unknown-prop`). Each declared key is accepted by the spec's strict `DashboardSchema`, so the manifest never offers a key the save gate refuses. The legacy `title` spelling and the retired `aria` key stay deliberately unpublished and are pinned as such; the `schema.title || schema.label` fallback read is unchanged, so documents in the wild keep rendering their header title. diff --git a/content/docs/plugins/plugin-dashboard.mdx b/content/docs/plugins/plugin-dashboard.mdx index 0a6f33da3e..3c6da6eeff 100644 --- a/content/docs/plugins/plugin-dashboard.mdx +++ b/content/docs/plugins/plugin-dashboard.mdx @@ -38,12 +38,32 @@ npm install @object-ui/plugin-dashboard { type: 'dashboard', widgets: Widget[], - columns?: number, // Grid columns (default: 3) - gap?: number, // Gap between widgets + label?: string | LocaleMap, // Header title — spec-canonical spelling; a string or { en, "zh-CN", ... } + description?: string | LocaleMap, // Header description, under the title + header?: { // Header block — strict: exactly these keys + showTitle?: boolean, + showDescription?: boolean, + actions?: { label, actionUrl?, actionType?, icon? }[] + }, + globalFilters?: GlobalFilter[], // Dashboard-level filter bar — see "Dashboard-level filters" + dateRange?: { // Built-in date-range filter — see "Dashboard-level filters" + field?: string, + defaultRange?: string, // a spec date preset, or 'custom' + allowCustomRange?: boolean + }, + refreshInterval?: number, // Auto-refresh period in seconds; runs only when the host wires onRefresh + columns?: number, // Grid columns (default: 3) + gap?: number, // Gap between widgets className?: string } ``` +The header renders only when `header` is declared, and costs zero pixels when +everything it would show is suppressed. The legacy `title` spelling of `label` +is still *read* (documents in the wild carry it) but is not authoring surface — +the spec rejects it by name, so new documents author `label`. The retired +`aria` key is neither read nor authorable. + ### Metric Card ```plaintext diff --git a/packages/plugin-dashboard/package.json b/packages/plugin-dashboard/package.json index 0c866f67b5..d3ec891f06 100644 --- a/packages/plugin-dashboard/package.json +++ b/packages/plugin-dashboard/package.json @@ -43,6 +43,7 @@ }, "devDependencies": { "@object-ui/plugin-charts": "workspace:*", + "@object-ui/sdui-parser": "workspace:*", "@objectstack/spec": "^17.0.0", "@types/react-grid-layout": "^2.1.0", "@vitejs/plugin-react": "^6.0.5", diff --git a/packages/plugin-dashboard/src/__tests__/dashboardAuthoredInputs.test.tsx b/packages/plugin-dashboard/src/__tests__/dashboardAuthoredInputs.test.tsx new file mode 100644 index 0000000000..06318c0a8a --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/dashboardAuthoredInputs.test.tsx @@ -0,0 +1,328 @@ +/** + * 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. + * + * `dashboard` — the honoured keys are published, per key, and the two ruled + * out stay pinned out (objectui#5742). + * + * ## The card + * + * The `dashboard` registration published exactly three inputs (`columns`, + * `gap`, `className`) while `DashboardRenderer` honoured `widgets`, `label` / + * legacy `title`, `description`, `header`, `globalFilters`, `dateRange` and + * `refreshInterval`. `inputs` is not documentation: it is the published + * authoring surface (`gen-manifest.ts` serializes it into + * `sdui.manifest.json` — the save gate and parser whitelist — and into + * `sdui-intrinsics.d.ts`, and `dashboard` is in `PUBLIC_BLOCKS`). So + * `validateTree` warned authors off keys that work — `widgets` included, the + * very prop whose CONTENTS the objectui#5709 `unconsumed-widget-option` + * warning reasons about, two diagnostics reading incoherently side by side. + * + * ## The per-key line (the shipped #4668 / #5091 precedents) + * + * A key is DECLARED only when both hold: the renderer reads it AND + * `@objectstack/spec`'s strict `DashboardSchema` accepts it — so the manifest + * never offers a key the save gate refuses. That line puts seven keys in and + * keeps two honest exclusions out: + * + * - `title` — the legacy objectui spelling of the spec-canonical `label` + * (framework#1878). The spec REJECTS it by name, so declaring it would + * publish a key an author could not save. The `schema.title || + * schema.label` read STAYS — documents in the wild carry it — which is + * exactly the #5091 shape: non-author surface, still read. + * - `aria` — the spec carries a TOMBSTONE for `dashboard.aria` (removed at + * the #3896 audit close-out, "no dashboard renderer ever applied it"). + * Measured here too: this package has NO read site for `schema.aria`, so + * unlike `title` there is no "renderer still reads it" leg — the issue + * body's listing of `aria` among the honoured reads was wrong on that one + * key. The pin is that it stays unpublished and spec-refused. + * + * ## The third exclusion: `name` — ruled non-author, on weaker evidence + * + * `schema.name` is read too (it keys the `dashboards.{name}.*` translation + * lookups), and it is NOT declared: objectui#5742 ruled it non-author for + * the INLINE node. + * + * Its reason is NOT `title`'s or `aria`'s, and that difference is the point. + * The spec ACCEPTS `name` — but on the DOCUMENT form, where it is REQUIRED, + * not on this inline node. So the per-key line above never fires here at + * all: its first clause ("the spec accepts it") is about a different shape. + * Do not read this exclusion as "the spec rejects `name`" — it does not, and + * a reader who sees `name` excluded beside `title` and assumes the same + * reason has it wrong. + * + * The `schema.name` read STAYS untouched, exactly as `title`'s does — the + * renderer still resolves `dashboards.{name}.*` through it. + * + * The evidence is the PRODUCER alone: `DashboardView` / the document loader + * hands the loaded document to the renderer, so an inline author is not the + * one who writes this key. That is a WEAKER pin than the two in `NON_AUTHOR` + * below, each of which asserts a spec verdict a reader can re-check — and it + * is why `name` carries no row there: there is no verdict for it to assert. + * Its absence from `inputs` is ruled, not merely unexamined; do not read that + * silence as the same strength of guarantee the other two carry. + * + * The supporting reason, had the above not already settled it: publishing + * `name` inline would teach authors — AI authors especially — to fabricate a + * dashboard identity that resolves NO translations and fails silently. That + * is a newly manufactured silently-inert key: the exact defect class this + * card removes. + * + * ## Why every positive has a control + * + * "No diagnostic" is also what a silenced check looks like: the undeclared + * probe key must still draw `unknown-prop`, the declared control must be + * published, and the spec must accept the full declared document — otherwise + * every absence/rejection assertion here would pass against a registry that + * published nothing or a schema that refuses everything (the same pairing + * `ga-honoured-inputs-author-reach.test.ts` and `gridNonAuthorKeys.test.tsx` + * use, for the same reason). + * + * Module-scope registration import, not a hook (AGENTS.md §测试纪律): the + * registration is the fixture, and its cold transform must not be billed to a + * bounded test/hook window. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import { ComponentRegistry } from '@object-ui/core'; +import { DashboardSchema } from '@objectstack/spec/ui'; +import { manifestFromConfigs, validateTree, generateDts, propsName } from '@object-ui/sdui-parser'; +import type { Diagnostic, SchemaElement } from '@object-ui/sdui-parser'; +import { ActionProvider } from '@object-ui/react'; +import { DashboardRenderer } from '../DashboardRenderer'; +// Module scope, not a hook: this import IS the registration. +import '../index'; + +/** + * The manifest exactly as `gen-manifest.ts` / `dump-public-manifest.mjs` + * build the published one — `getPublicConfigs()` through + * `manifestFromConfigs` — so the verdicts below are the ones a real author + * gets, not ones a hand-written fixture was shaped to produce. (Only this + * package's registrations are loaded here; `dashboard` is among them, which + * is all these assertions read.) + */ +const manifest = manifestFromConfigs( + ComponentRegistry.getPublicConfigs() as unknown as Parameters[0], +); + +const diagnose = (node: Record): Diagnostic[] => + validateTree({ type: 'dashboard', ...node } as unknown as SchemaElement, manifest).diagnostics; + +/** Diagnostics naming a specific prop (the messages quote prop names). */ +const codesMentioning = (node: Record, prop: string): string[] => + diagnose(node) + .filter((d) => d.message.includes(`"${prop}"`)) + .map((d) => d.code); + +/** The spec's verdict on a minimal legal document plus one patch. */ +const specVerdict = (patch: Record) => + DashboardSchema.safeParse({ name: 'sales_ops', label: 'Sales Ops', widgets: [], ...patch }); + +/** Unrecognized KEYS from a failed parse — a key verdict, never a document one. */ +const unrecognizedKeys = (result: { success: boolean; error?: unknown }): string[] => + ((result as { error?: { issues: Array<{ code: string; keys?: string[] }> } }).error?.issues ?? []) + .filter((issue) => issue.code === 'unrecognized_keys') + .flatMap((issue) => issue.keys ?? []); + +/** + * The newly published keys, each with a spec-legal sample value. The sample + * doubles as the spec-acceptance evidence: `full declared document` below + * parses all of them at once. + */ +const DECLARED: Array<[string, unknown]> = [ + ['widgets', [{ id: 'w1', type: 'bar', dataset: 'invoices', values: ['count'] }]], + ['label', 'Sales Overview'], + ['label', { en: 'Sales Overview', 'zh-CN': '销售总览' }], + ['description', 'The numbers behind the pipeline'], + ['description', { en: 'The numbers behind the pipeline' }], + ['header', { showTitle: false, actions: [{ label: 'Open', actionUrl: '/x' }] }], + ['globalFilters', [{ field: 'region', label: 'Region', type: 'select' }]], + ['dateRange', { field: 'created_at', defaultRange: 'last_30_days' }], + ['refreshInterval', 30], +]; + +/** Values matching NO declared arm — each must still be reported. */ +const OFF_ARM: Array<[string, unknown]> = [ + ['widgets', {}], + ['label', 42], + ['description', 42], + ['header', true], + ['globalFilters', 'region'], + ['dateRange', []], + ['refreshInterval', '30'], +]; + +/** The two keys ruled OUT, with the evidence a reader can re-check. */ +const NON_AUTHOR = [ + { + key: 'title', + sample: 'Legacy Ops', + why: + 'legacy spelling of the spec-canonical `label` (framework#1878) — `DashboardSchema` ' + + 'rejects it by name, so publishing it would offer a key the save gate refuses', + }, + { + key: 'aria', + sample: { ariaLabel: 'Ops' }, + why: + 'spec tombstone (#3896 audit close-out): no dashboard renderer ever applied it, and ' + + 'this package has no `schema.aria` read site', + }, +] as const; + +afterEach(cleanup); + +describe('the manifest resolves `dashboard`, and the check is live (objectui#5742)', () => { + it('resolves the block (reachability before any absence claim)', () => { + expect(manifest.components['dashboard']).toBeTruthy(); + expect(diagnose({}).map((d) => d.code)).not.toContain('unknown-component'); + }); + + it('an undeclared key still draws unknown-prop — the control', () => { + expect(codesMentioning({ objectui5742NotAProp: 'x' }, 'objectui5742NotAProp')).toContain( + 'unknown-prop', + ); + }); +}); + +describe('the honoured keys now validate clean on an inline dashboard node (objectui#5742)', () => { + it.each(DECLARED)('%s draws no diagnostic', (key, value) => { + expect(codesMentioning({ [key]: value }, key), `dashboard.${key}`).toEqual([]); + }); + + it.each(OFF_ARM)('%s still rejects an off-arm value — declaring is not disarming', (key, value) => { + expect(codesMentioning({ [key]: value }, key)).toContain('type-mismatch'); + }); + + it('the spec accepts the full declared document — the declarations rest on its verdicts', () => { + const result = specVerdict({ + description: 'x', + header: { showTitle: true }, + columns: 4, + gap: 6, + refreshInterval: 30, + dateRange: { field: 'created_at', defaultRange: 'last_30_days' }, + globalFilters: [{ field: 'region', label: 'Region', type: 'select' }], + }); + expect(result.success, JSON.stringify((result as { error?: unknown }).error ?? {})).toBe(true); + }); + + it('both arms of the two union keys are spec-derived, not guessed', () => { + // `label` / `description` are `string | inline locale map` on the spec — + // the declared `['string', 'object']` arms restate exactly that, and a + // kind matching neither arm is refused by BOTH authorities. + for (const key of ['label', 'description']) { + expect(specVerdict({ [key]: 'plain' }).success).toBe(true); + expect(specVerdict({ [key]: { en: 'plain', 'zh-CN': '文' } }).success).toBe(true); + expect(specVerdict({ [key]: 42 }).success).toBe(false); + } + }); +}); + +describe('the two ruled-out keys stay unpublished — and checkably so (objectui#5742)', () => { + const inputNames = (namespace?: string): string[] => + ((ComponentRegistry.getConfig('dashboard', namespace) as { inputs?: Array<{ name: string }> }) + ?.inputs ?? []).map((i) => i.name); + + it.each([undefined, 'view'] as const)( + 'the registration publishes neither, looked up %s', + (namespace) => { + const declared = inputNames(namespace); + for (const { key, why } of NON_AUTHOR) { + expect(declared, `\`dashboard\` now publishes \`${key}\` — but ${why}.`).not.toContain(key); + } + // The declared controls: absence above means something only while the + // same registration really publishes the ruled-in surface. + expect(declared).toContain('label'); + expect(declared).toContain('widgets'); + }, + ); + + it('the spec rejects `title` by name — the exclusion is checkable', () => { + const result = specVerdict({ title: 'Legacy Ops' }); + expect(result.success, 'the spec now ACCEPTS dashboard.title — re-open objectui#5742').toBe(false); + expect(unrecognizedKeys(result)).toContain('title'); + }); + + it('the spec refuses every `aria` value — the tombstone is still standing', () => { + const result = specVerdict({ aria: { ariaLabel: 'Ops' } }); + expect(result.success, 'the spec re-admitted dashboard.aria — re-open objectui#5742').toBe(false); + const ariaIssue = (result as { error: { issues: Array<{ path: unknown[]; message: string }> } }) + .error.issues.find((i) => i.path.join('.') === 'aria'); + expect(ariaIssue, 'no issue at path `aria`').toBeTruthy(); + // The tombstone names the removal; a mere shape error would not. + expect(ariaIssue!.message).toMatch(/removed/); + }); + + it.each(NON_AUTHOR)('$key draws unknown-prop from the real validator — the ruled outcome', ({ key, sample }) => { + expect( + codesMentioning({ [key]: sample }, key), + `\`${key}\` no longer draws \`unknown-prop\`. If that is deliberate it means the key was` + + ' declared — which the objectui#5742 triage forbids for this key.', + ).toContain('unknown-prop'); + }); +}); + +describe('the legacy `title` read stays — non-author surface, still honoured (objectui#5742)', () => { + const renderDashboard = (schema: Record) => + render( + + + , + ); + + it('a wild document carrying only the legacy spelling keeps its header title', () => { + renderDashboard({ title: 'Legacy Ops' }); + expect(screen.getByRole('heading', { name: 'Legacy Ops' })).toBeInTheDocument(); + }); + + it('the canonical spelling renders too — the read above is the fallback, not the contract', () => { + renderDashboard({ label: 'Canonical Ops' }); + expect(screen.getByRole('heading', { name: 'Canonical Ops' })).toBeInTheDocument(); + }); +}); + +describe('the published artifacts carry the change — same generators as gen-manifest (objectui#5742)', () => { + it('the manifest entry publishes exactly the triaged input list', () => { + // Exact list, not `toContain`: the failure mode both ways is silent — a + // shrink un-publishes a key authors rely on, a growth publishes one the + // triage ruled out. + expect(manifest.components['dashboard'].inputs.map((i) => i.name)).toEqual([ + 'widgets', + 'label', + 'description', + 'header', + 'globalFilters', + 'dateRange', + 'refreshInterval', + 'columns', + 'gap', + 'className', + ]); + }); + + it('the generated JSX intrinsics type the new keys, unions included', () => { + const dts = generateDts(manifest); + const match = dts.match( + new RegExp(`export interface ${propsName('dashboard')} extends SduiBaseProps \\{[^}]*\\}`), + ); + expect(match, `no ${propsName('dashboard')} interface in the generated d.ts`).toBeTruthy(); + const block = match![0]; + expect(block).toContain('widgets?: unknown[];'); + expect(block).toContain('label?: string | Record;'); + expect(block).toContain('description?: string | Record;'); + expect(block).toContain('header?: Record;'); + expect(block).toContain('globalFilters?: unknown[];'); + expect(block).toContain('dateRange?: Record;'); + expect(block).toContain('refreshInterval?: number;'); + // The exclusions stay out of the type surface an author compiles against. + expect(block).not.toMatch(/\btitle\b/); + expect(block).not.toMatch(/\baria\b/); + }); +}); diff --git a/packages/plugin-dashboard/src/index.tsx b/packages/plugin-dashboard/src/index.tsx index 4a1351527e..a53d1b8289 100644 --- a/packages/plugin-dashboard/src/index.tsx +++ b/packages/plugin-dashboard/src/index.tsx @@ -40,6 +40,42 @@ export type { } from './dataset-catalog'; // Register dashboard component +// +// objectui#5742 — `inputs` is the published authoring surface (serialized into +// `sdui.manifest.json` and `sdui-intrinsics.d.ts`; `dashboard` is in +// `PUBLIC_BLOCKS`), and it used to publish only `columns`/`gap`/`className` +// while `DashboardRenderer` honoured far more, so `validateTree` warned +// authors off keys that work — `widgets` included, the very key the +// objectui#5709 unconsumed-options warning descends into. The keys below are +// the per-key triage (#4668 / #5091 class), each declared because BOTH hold: +// the renderer reads it AND `@objectstack/spec`'s strict `DashboardSchema` +// accepts it, so the manifest never offers a key the save gate refuses. +// +// Deliberately NOT declared, pinned in +// `__tests__/dashboardAuthoredInputs.test.tsx`: +// - `title` — legacy spelling of `label`; the spec rejects it by name. +// The `schema.title || schema.label` read STAYS (documents in the wild). +// - `aria` — spec tombstone (#3896 audit close-out): no dashboard +// renderer ever applied it, and this package has no read site either. +// +// `name` is honoured too (the `schema.name` read keys the +// `dashboards.{name}.*` translation lookups) and is likewise NOT declared — +// objectui#5742 ruled it non-author for the INLINE node. Its reason is NOT +// the one above, and the difference is load-bearing: the spec ACCEPTS +// `name` — but on the DOCUMENT form, where it is required, not on this +// inline node. So the "spec accepts + renderer reads" line never fires here +// at all; its premise is about a different shape. Do not carry `title`'s +// "the spec rejects it" over to this key — the spec does not reject `name`. +// The `schema.name` read STAYS untouched. +// The evidence is the PRODUCER alone — `DashboardView` / the document loader +// hands the loaded document to the renderer, so an inline author is not the +// one who writes this key. That makes it a WEAKER exclusion than `title` / +// `aria`, each of which asserts a spec verdict a reader can re-check, and is +// why `name` carries no row in the pin test's table: there is no verdict for +// it to assert. Supporting reason: publishing `name` inline would teach +// authors — AI authors especially — to fabricate a dashboard identity that +// resolves NO translations and fails silently, minting a fresh +// silently-inert key, the exact defect class this card removes. ComponentRegistry.register( 'dashboard', DashboardRenderer, @@ -49,6 +85,13 @@ ComponentRegistry.register( category: 'Complex', icon: 'layout-dashboard', inputs: [ + { name: 'widgets', type: 'array', label: 'Widgets', description: 'The widget tree — the spec’s DashboardWidget[]. Each widget binds a dataset (ADR-0021) and may carry a layout ({ x, y, w, h }) and filterBindings. When omitted the dashboard renders an empty grid.' }, + { name: 'label', type: ['string', 'object'], label: 'Label', description: 'Display name, shown as the header title when `header` is declared — a string or an inline per-locale map such as { en, "zh-CN" }. Spec-canonical spelling; the legacy `title` spelling is not authoring surface.' }, + { name: 'description', type: ['string', 'object'], label: 'Description', description: 'Header description shown under the title — a string or an inline per-locale map. Rendered only when `header` is declared and `header.showDescription` is not false.' }, + { name: 'header', type: 'object', label: 'Header', description: 'Header block: { showTitle?, showDescription?, actions? }. Strict — the contract rejects any other key. Renders nothing (zero pixels) when everything it would show is suppressed.' }, + { name: 'globalFilters', type: 'array', label: 'Global Filters', description: 'Dashboard-level filter bar — the spec’s GlobalFilter[]. Filter values live as dashboard variables (readable in widget expressions as page.) and are AND-merged into each bound widget’s query per its filterBindings.' }, + { name: 'dateRange', type: 'object', label: 'Date Range', description: 'Built-in date-range filter: { field?, defaultRange?, allowCustomRange? }. `defaultRange` takes the spec’s date presets plus "custom"; the bound field defaults to created_at.' }, + { name: 'refreshInterval', type: 'number', label: 'Refresh Interval', description: 'Auto-refresh period in seconds. Zero or a negative value disables the timer, and it only runs when the host wires an onRefresh handler.' }, { name: 'columns', type: 'number', label: 'Columns', defaultValue: 3 }, { name: 'gap', type: 'number', label: 'Gap', defaultValue: 4 }, { name: 'className', type: 'string', label: 'CSS Class' } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 813448e6f2..5cf667a059 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1763,6 +1763,9 @@ importers: '@object-ui/plugin-charts': specifier: workspace:* version: link:../plugin-charts + '@object-ui/sdui-parser': + specifier: workspace:* + version: link:../sdui-parser '@objectstack/spec': specifier: ^17.0.0 version: 17.2.0(ai@7.0.65(zod@4.4.3))