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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/7952-dashboard-widgets-component-arm.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@object-ui/types": minor
---

`DashboardComponentSchema.widgets` gains the component-node arm on the TypeScript face (objectui#7952)

`widgets` was `DashboardWidgetSchema[]` in TypeScript while the zod schema it mirrors has been a two-arm union since the 2026-08-14 ruling (objectstack#8593): a component node placed directly in the widget slot (`type: 'metric-card'`, body validated as passthrough `BaseSchema`) or a spec-family widget. So the shape `@object-ui/plugin-dashboard`'s README teaches in every `metric-card` example — and the shape the shipped `DashboardRenderer` renders — parsed green under `safeParse` and was refused by `tsc --strict` (`TS2561: 'value' does not exist in type 'DashboardWidgetSchema'`, six occurrences across the README's dashboard blocks at `fc32921`). There was no annotation an author could write for a document the platform accepts.

**Accept-set change (Clause ②, TypeScript face only).** `widgets` is now `Array<DashboardWidgetSlotComponentSchema | DashboardWidgetSchema>`, and `DashboardWidgetSlotComponentSchema` — `BaseSchema` with `type` narrowed to the closed `DASHBOARD_COMPONENT_WIDGET_TYPES` — is a new export of `@object-ui/types`. The zod schema is unchanged; `DashboardWidgetSchema` is NOT widened with `value` / `icon` / `trend` / `trendValue` (those are `MetricCard`'s registry inputs, not widget keys — the compiler's `Did you mean to write 'values'?` points at the repair both declarations forbid).

**What still refuses.** A widget that names a spec-family `type` and carries an undeclared key (`{ type: 'bar', bogus: 1 }`) is still a `tsc` error: the literal is discriminated by `type`, so the passthrough arm never applies to it. A `type` outside both vocabularies is refused as before. The one corner the TypeScript union cannot discriminate — a legacy `component` envelope with NO `type` plus an undeclared key — compiles on the TypeScript face and is refused by name at validation, as every `BaseSchema` slot already behaves.

**Consumers.** The new arm is assignable to `DashboardWidgetSchema`, so code that annotates a widget callback `(w: DashboardWidgetSchema)` keeps compiling unchanged. Code that reads a property off an unannotated element of `schema.widgets` now sees the union, and through `BaseSchema`'s index signature that read is `any` rather than the widget's declared type — annotate the parameter to keep the narrower type.
24 changes: 21 additions & 3 deletions packages/plugin-dashboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -426,12 +426,14 @@ The authored shape is typed by `@object-ui/types`:
| --- | --- |
| `DashboardComponentSchema` | the whole `type: 'dashboard'` node — `columns`, `gap`, `widgets`, `header`, `globalFilters`, `dateRange`, `refreshInterval`, … |
| `DashboardWidgetSchema` | one entry of `widgets[]` — the spec's `DashboardWidget` keys, plus objectui's own (`component`, `layout`, `options`, …) |
| `DashboardWidgetSlotComponentSchema` | the other kind of `widgets[]` entry — a component node placed directly in the slot, `type` one of the closed component set (`metric-card`); every other key is that component's own prop |
| `DashboardWidgetLayout` | a widget's `{ x, y, w, h }` grid box |

```typescript
import type {
DashboardComponentSchema,
DashboardWidgetSchema,
DashboardWidgetSlotComponentSchema,
} from '@object-ui/types';

// Dataset-bound KPI — the widget vocabulary (see "Dashboard-level filters").
Expand Down Expand Up @@ -466,11 +468,22 @@ const custom: DashboardWidgetSchema = {
layout: { x: 0, y: 0, w: 3, h: 2 },
};

// Component node directly in the slot — the shape every `metric-card` example
// above uses. `type` is one of the closed component set; the other keys are
// the component's own props, carried by `BaseSchema`'s index signature.
const kpi: DashboardWidgetSlotComponentSchema = {
type: 'metric-card',
title: 'Revenue',
value: '$123,456',
trend: 'up',
trendValue: '+12%',
};

const dashboard: DashboardComponentSchema = {
type: 'dashboard',
columns: 3,
gap: 4,
widgets: [revenue, users, custom],
widgets: [revenue, users, custom, kpi],
};
```

Expand All @@ -479,8 +492,13 @@ covers every `type` (`metric`, `bar`, `table`, …) and the family-specific
settings live under `options`. `MetricCard`'s own props — `value`, `trend`,
`trendValue` — are the component's, not the widget's: `DashboardWidgetSchema`
declares none of them, and the component's props interface is not on this
package's export surface either. So a `metric-card` node is typed only where it
appears as a component (the `component` slot above), not as a widget family.
package's export surface either. A `metric-card` node is typed as a COMPONENT
node in both places it can appear: in a widget's `component` slot (`custom`
above) and directly in `widgets[]` (`kpi` above, `DashboardWidgetSlotComponentSchema`
— the component arm of `DashboardComponentSchema['widgets']`, first in the
declaration as in the zod schema's two-arm slot). Its keys are checked as
`BaseSchema` keys either way,
never as widget keys.

## Customization

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
/**
* 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.
*/

/**
* objectui#7952 — `DashboardComponentSchema.widgets` carries the component-node
* arm on the TypeScript face, matching the Zod twin's two-arm slot.
*
* ## The gap this closes
*
* `zod/complex.zod.ts` has routed a `metric-card` node placed directly in the
* widget slot to passthrough `BaseSchema` since the 2026-08-14 ruling
* (objectstack#8593): `widgets: z.array(z.union([DashboardWidgetSlotComponentSchema,
* DashboardWidgetSchema]))`. The TypeScript declaration stayed one-armed,
* `DashboardWidgetSchema[]`. Measured at `fc32921` on the six dashboard blocks
* `plugin-dashboard/README.md` teaches, each annotated `DashboardComponentSchema`:
* `safeParse` ACCEPT with every authored key preserved; `tsc --strict` 6 × TS2561
* (`'value' does not exist in type 'DashboardWidgetSchema'. Did you mean to
* write 'values'?`). No annotation existed for a document the platform accepts
* and the maintainer ruled legal. Ruled option (a), director seat, decision
* batch #68 (2026-09-07): the TypeScript face gains the arm; the Zod face is
* untouched and `DashboardWidgetSchema` is NOT widened.
*
* ## What is pinned, and on which face
*
* 1. the README's `metric-card` shape annotates and compiles (type level) AND
* parses green with its keys kept (runtime) — the two halves of the card's
* measurement, now agreeing;
* 2. the forbidden repair did not happen: `DashboardWidgetSchema` still refuses
* `value` — an `@ts-expect-error` that turns into TS2578 if anyone widens it;
* 3. the arm is CLOSED on `type` and the union still discriminates: a
* spec-family widget with an undeclared key is refused on both faces, and a
* `type` in neither vocabulary is refused;
* 4. the measured limit of a TypeScript union with a passthrough arm, recorded
* two-faced so it cannot be read as a hatch: a `type`-less legacy envelope
* with an undeclared key COMPILES (nothing to discriminate on, so the arm's
* index signature satisfies the excess-property check) while the Zod face
* refuses it by name;
* 5. shape identity: the slot's element type IS the two-arm union, the arm's
* `type` IS `DashboardComponentWidgetType`, and the arm is assignable to
* `DashboardWidgetSchema` — which is why every `(w: DashboardWidgetSchema)`
* callback in `plugin-dashboard` compiled unchanged.
*
* Type-level lines are erased at runtime and enforced because
* `packages/types/tsconfig.test.json` is chained from this package's
* `type-check` script (objectui#3009). Reverse-verified at the PR: with `widgets`
* restored to `DashboardWidgetSchema[]`, `tsc -p tsconfig.test.json` goes red on
* the lines marked REVERSE below and nowhere else in this file.
*/

import { describe, it, expect } from 'vitest';
import type {
DashboardComponentSchema,
DashboardComponentWidgetType,
DashboardWidgetSchema,
DashboardWidgetSlotComponentSchema,
} from '../complex.js';
import { DASHBOARD_COMPONENT_WIDGET_TYPES } from '../complex.js';
import { DashboardComponentSchema as DashboardComponentZod } from '../zod/complex.zod.js';

type Equal< A, B > =
(< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false;

/**
* `packages/plugin-dashboard/README.md`'s "Usage" block, byte-for-byte, with the
* annotation the card measured it under. The runtime half of the same document
* is read off the page by `plugin-dashboard`'s
* `readme-dashboard-examples-spec-valid.test.ts`; this copy exists because a
* type-level pin cannot read a file.
*/
// REVERSE — TS2561 on `value` with the one-arm declaration.
const usage: DashboardComponentSchema = {
type: 'dashboard',
widgets: [
{
type: 'metric-card',
title: 'Total Sales',
value: '$123,456',
trend: 'up',
trendValue: '+12%'
}
]
};

describe('the component-node arm is declared on the TypeScript face (objectui#7952)', () => {
it('the README shape annotates, compiles, and parses green with every key kept', () => {
const result = DashboardComponentZod.safeParse(usage);
expect(result.success).toBe(true);
if (!result.success) return;
const parsed = (result.data as { widgets: Record<string, unknown>[] }).widgets[0];
for (const key of Object.keys(usage.widgets[0])) expect(parsed).toHaveProperty(key);
});

it('the slot element IS the two-arm union, in the Zod twin\'s order', () => {
type Element = DashboardComponentSchema['widgets'][number];
// REVERSE — `Element` collapses to `DashboardWidgetSchema` and this is `false`.
const twoArm: Equal< Element, DashboardWidgetSlotComponentSchema | DashboardWidgetSchema > = true;
// The arm's `type` is the closed component set, by reference — not a copy.
const closedByReference: Equal< DashboardWidgetSlotComponentSchema['type'], DashboardComponentWidgetType > = true;
// The arm is assignable to the widget type: consumers annotating a widget
// callback `(w: DashboardWidgetSchema)` keep compiling on the union.
const armAssignable: DashboardWidgetSlotComponentSchema extends DashboardWidgetSchema ? true : false = true;
expect(twoArm && closedByReference && armAssignable).toBe(true);
// The runtime side of "by reference": the set the arm keys on is the one
// export, and it is the set the Zod arm reads.
expect(DASHBOARD_COMPONENT_WIDGET_TYPES).toContain(usage.widgets[0].type);
});
});

describe('the forbidden repair did not happen — DashboardWidgetSchema is not widened', () => {
it('`value` is still not a widget key on the TypeScript face', () => {
// `value` / `icon` / `trend` / `trendValue` are `MetricCard`'s registry
// inputs. The compiler's own suggestion for this line ("Did you mean to
// write 'values'?") is the repair both declarations forbid; if anyone
// makes it, this directive goes unused (TS2578) and `type-check` fails.
// @ts-expect-error — TS2561: 'value' does not exist in type 'DashboardWidgetSchema'.
const widened: DashboardWidgetSchema = { type: 'metric-card', value: '1' };
expect(widened.type).toBe('metric-card');
});

it('the arm\'s `type` is closed', () => {
// @ts-expect-error — TS2322: a spec family is not a component type.
const open: DashboardWidgetSlotComponentSchema = { type: 'bar' };
expect(open.type).toBe('bar');
});
});

describe('NOT A HATCH — what the union still refuses, on both faces', () => {
it('a spec-family widget with an undeclared key is discriminated by `type` and refused', () => {
const doc: DashboardComponentSchema = {
type: 'dashboard',
widgets: [
// `'bar'` excludes the component arm, so the excess-property check runs
// against `DashboardWidgetSchema` alone.
// @ts-expect-error — TS2353: 'bogus' does not exist in type 'DashboardWidgetSchema'.
{ type: 'bar', title: 'x', bogus: 1 },
],
};
const result = DashboardComponentZod.safeParse(doc);
expect(result.success).toBe(false);
if (result.success) return;
const flat = JSON.stringify(result.error.issues);
expect(flat).toContain('unrecognized_keys');
expect(flat).toContain('bogus');
});

it('a `type` in neither vocabulary is refused', () => {
const doc: DashboardComponentSchema = {
type: 'dashboard',
widgets: [
// @ts-expect-error — TS2322: not a widget family, not a component type.
{ type: 'not-a-component', value: '1' },
],
};
expect(DashboardComponentZod.safeParse(doc).success).toBe(false);
});

it('a component node with an undeclared key is kept whole — that is the passthrough, by ruling', () => {
const doc: DashboardComponentSchema = {
type: 'dashboard',
widgets: [{ type: 'metric-card', title: 'x', someProp: 1 }],
};
const result = DashboardComponentZod.safeParse(doc);
expect(result.success).toBe(true);
if (!result.success) return;
expect((result.data as { widgets: Record<string, unknown>[] }).widgets[0]).toHaveProperty('someProp', 1);
});
});

describe('MEASURED LIMIT of a TypeScript union with a passthrough arm — recorded, not a contract', () => {
it('a `type`-less legacy envelope with an undeclared key compiles, and the Zod face refuses it by name', () => {
// Nothing to discriminate on (the legacy `component` envelope has no
// `type`), so the union's excess-property check accepts any key one arm
// could hold, and the component arm's index signature holds every key.
// If tsc ever refuses this literal, the corner has closed: delete this
// constant and the note on `widgets` in `complex.ts` — ⛔ do not add an
// `@ts-expect-error` to keep the file green.
const envelopeStray: DashboardComponentSchema = {
type: 'dashboard',
widgets: [{ id: 'w', component: { type: 'metric-card', value: '1' }, bogus: 1 }],
};
const result = DashboardComponentZod.safeParse(envelopeStray);
expect(result.success, 'the runtime is the strict face on this corner').toBe(false);
if (result.success) return;
const flat = JSON.stringify(result.error.issues);
expect(flat).toContain('unrecognized_keys');
expect(flat).toContain('bogus');
});

it('the same envelope without the stray key is legal on both faces', () => {
const envelope: DashboardComponentSchema = {
type: 'dashboard',
widgets: [{ id: 'w', component: { type: 'metric-card', value: '1' }, layout: { x: 0, y: 0, w: 1, h: 1 } }],
};
expect(DashboardComponentZod.safeParse(envelope).success).toBe(true);
});
});
72 changes: 70 additions & 2 deletions packages/types/src/complex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1719,7 +1719,9 @@ export interface FloatingChatbotConfig {
* `plugin-dashboard/src/index.tsx`). Those props are NOT widget keys and must
* not be added to {@link DashboardWidgetSchema}; a member of this list is
* validated as a component node against objectui's own passthrough
* `BaseSchema`, which is what keeps them.
* `BaseSchema`, which is what keeps them. On the TypeScript face that node is
* {@link DashboardWidgetSlotComponentSchema}, the component arm — first, as
* in the Zod twin — of `DashboardComponentSchema.widgets` (objectui#7952).
*
* ⛔ CLOSED on purpose. The ruling's triage block named an open
* "extension allowed" hatch as the thing to avoid: an open hatch re-creates
Expand Down Expand Up @@ -1869,6 +1871,49 @@ export interface DashboardWidgetSchema
pagination?: boolean;
}

/**
* A COMPONENT node sitting directly in a dashboard's widget slot — the
* `metric-card` extension the 2026-08-14 ruling (objectstack#8593) admits, on
* the TypeScript face. Twin of `zod/complex.zod.ts`
* `DashboardWidgetSlotComponentSchema`, spelled the same way that arm is:
* `BaseSchema` plus a `type` narrowed to the CLOSED component set
* ({@link DASHBOARD_COMPONENT_WIDGET_TYPES}).
*
* `BaseSchema`'s `[key: string]: any` is the passthrough. `value` / `icon` /
* `trend` / `trendValue` are `MetricCard`'s registry `inputs`, not widget keys:
* they reach the compiler through the index signature here and MUST NOT be
* declared on {@link DashboardWidgetSchema} — the compiler's own TS2561
* suggestion ("Did you mean to write 'values'?") points at exactly that
* forbidden repair.
*
* Until objectui#7952 this arm existed on the Zod face only:
* `DashboardComponentSchema.widgets` was `DashboardWidgetSchema[]`, so the
* `metric-card` blocks `plugin-dashboard/README.md` teaches parsed green
* under `safeParse` and `tsc --strict` refused every one of them (6 × TS2561
* on `value`, measured at `fc32921`). Ruled option (a) by the director seat
* (decision batch #68, 2026-09-07, maintainer 「同意」): the TypeScript face
* gains the component arm; the Zod face is untouched.
*
* Exported on purpose, where the Zod twin is not. The compiler does not force
* it — measured in this package's own build (`declaration` + `composite`,
* TypeScript 6.0.3): a non-exported arm referenced from the exported
* `DashboardComponentSchema` emits into `dist/complex.d.ts` as a local
* interface, exit 0, and a barrel consumer still writes the node with no
* name. The export is an authoring-surface decision — a name an author can
* annotate the node with, which `plugin-dashboard/README.md` teaches — taken
* deliberately in the opposite direction to the Zod arm, whose own docblock
* keeps that const private because its routing is an internal property of
* the slot. Same interface shape as {@link DashboardComponentSchema} itself
* (`extends BaseSchema` + a literal `type`), which is why it is an interface
* rather than an intersection.
*
* Pinned by `__tests__/dashboard-widget-slot-component-arm-7952.test.ts`.
*/
export interface DashboardWidgetSlotComponentSchema extends BaseSchema {
/** An objectui component type legal in a widget slot — the CLOSED set. */
type: DashboardComponentWidgetType;
}

/**
* Dashboard Schema
*/
Expand All @@ -1894,7 +1939,30 @@ export interface DashboardComponentSchema extends BaseSchema {
// `__tests__/dashboard-title-retired-declaration.test.ts`.
columns?: number;
gap?: number;
widgets: DashboardWidgetSchema[];
/**
* The widget slot — TWO arms, matching the Zod twin (`zod/complex.zod.ts`
* `DashboardComponentSchema.widgets`, component arm first): a component
* node ({@link DashboardWidgetSlotComponentSchema}, `type` in the closed
* {@link DASHBOARD_COMPONENT_WIDGET_TYPES}) or a spec-family / legacy
* `component`-envelope widget ({@link DashboardWidgetSchema}).
*
* One-armed (`DashboardWidgetSchema[]`) until objectui#7952, which refused
* the shape the 2026-08-14 ruling (objectstack#8593) admits and the runtime
* renders — see the arm's own docblock for the measurement and the ruling.
*
* ⚠️ Measured limits of a TypeScript union with a passthrough arm, recorded
* so nobody reads them as a hatch (pinned two-faced next to the arm):
* - a literal that NAMES a `type` outside the component set is discriminated
* by it — `{ type: 'bar', bogus: 1 }` is still refused, because `'bar'`
* excludes the component arm and `DashboardWidgetSchema` has no `bogus`;
* - a literal with NO `type` (the legacy `component` envelope) cannot be
* discriminated, and the component arm's index signature then satisfies
* the union's excess-property check, so `{ component, bogus: 1 }`
* compiles here. The Zod face refuses it (`.strict()` widget schema) —
* the runtime is the strict face on that corner, as it already was for
* every `BaseSchema` slot.
*/
widgets: Array<DashboardWidgetSlotComponentSchema | DashboardWidgetSchema>;
/** Auto-refresh interval in seconds. When set, the dashboard will periodically trigger onRefresh. */
refreshInterval?: number;
/**
Expand Down
Loading
Loading