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
24 changes: 24 additions & 0 deletions .changeset/11709-envelope-metric-card-arm.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@object-ui/types': minor
---

Inside a dashboard widget's legacy `component` envelope, a `metric-card` is now judged by the slot's component arm alone, so every key that arm refuses is refused there too, with the arm's own message (objectui#11709).

BREAKING (`@object-ui/types`): a widget's `component` envelope that holds a `metric-card` the component arm refuses is now refused by `objectui validate`. (The bump is `minor` by this repo's release model: objectui's major follows the `@objectstack` family major, and its own breaking changes ship as `minor` with the breaking semantics stated here.)

- FROM `{ "id": "w", "component": { "type": "metric-card", "value": "$123,456", "label": "Total Revenue" } }`
- TO `{ "id": "w", "component": { "type": "metric-card", "value": "$123,456", "title": "Total Revenue" } }`

Why: the envelope's `component` slot was a plain union of the component arm and passthrough `BaseSchema`. A union takes the first arm that parses, and `BaseSchema` admits any node, so a card the arm refused parsed through it on the tolerant face (`safeValidateSchema`, which `objectui validate` runs). That covered `label`, `children`, a `trend` outside `up | down | neutral`, a card with no `value`, and every other value the arm's members refuse. The strict authoring face and the TypeScript twin already refused the same documents. `body` was refused, but by both arms, so the validator printed `BaseSchema`'s message beside the arm's.

**Migration.** Write the card the way it is written directly in `widgets[]`: `title` for the heading, a `value`, `trend` from its enum, and no `body` or `children`. A card the strict face accepts parses exactly as before.

What each face does now:

- **zod (`@object-ui/types/zod`), tolerant face.** NARROWS. The slot reads the node's `type` before the union runs. A `type` the component arm declares (`metric-card`) goes to that arm alone, so its issues come back at their own paths with the arm's own messages, and no `BaseSchema` reading stands beside them. That is the shape the same card already has directly in `widgets[]`. Every other node goes to the same two-arm union as before, so a `custom` widget's `component` keeps the passthrough.
- **zod, strict authoring face.** It already refused the documents above. It is derived from the same slot, so its refusal now also arrives as the arm's issues alone. It NARROWS in one corner: a card with no `value` that carries only `BaseSchema` keys (`{ "type": "metric-card" }`, or that plus `id` / `className`) used to parse there through the strict `BaseSchema` arm, and is refused now for its missing `value`.
- **TypeScript.** Unchanged. `DashboardWidgetSchema.component` already refused every literal above, that corner included.

**Supersedes a line of objectui#4425's note.** That note says the tolerant face still accepts a `label` inside the `component` envelope, through the envelope's `BaseSchema` fallback. It is refused there now.

Nothing widens, and no export is added. The routing is module-private, like the component arm itself.
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,13 @@
* reads, is refused by name on the tolerant face, the strict face and the
* TypeScript twin, and the message names `title`, the heading the card draws.
* That the same card with `title` draws its heading is pinned where it renders,
* `@object-ui/plugin-dashboard`'s `metricCardLabelRefusedTitleHeading-4425.test.tsx`.
* `@object-ui/plugin-dashboard`'s `metricCardLabelRefusedTitleHeading-4425.test.tsx`;
* 8. objectui#11709: inside a widget's legacy `component` envelope, a `metric-card`
* is judged by the arm ALONE on the tolerant face too (`safeValidateSchema`,
* which `objectui validate` runs). One enumeration pin derives every refusal
* from the arm's own members and proves each is refused there with the
* member's own message, and with no `BaseSchema` fallback beside it. It
* replaced the two "MEASURED LIMIT" notes that recorded the fallback.
*
* Type-level lines are erased at runtime and enforced because
* `packages/types/tsconfig.test.json` is chained from this package's
Expand All @@ -79,8 +85,8 @@ import type {
DashboardWidgetSlotComponentSchema,
} from '../complex.js';
import { DASHBOARD_COMPONENT_WIDGET_TYPES } from '../complex.js';
import { DashboardComponentSchema as DashboardComponentZod } from '../zod/complex.zod.js';
import { StrictAnyComponentSchema } from '../zod/index.zod.js';
import { DashboardComponentSchema as DashboardComponentZod, DashboardWidgetSchema as DashboardWidgetZod } from '../zod/complex.zod.js';
import { StrictAnyComponentSchema, safeValidateSchema } from '../zod/index.zod.js';

type Equal< A, B > =
(< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false;
Expand Down Expand Up @@ -210,10 +216,12 @@ describe('the legacy `component` envelope on the TypeScript face (objectui#7952,
// @ts-expect-error — `value` is the card's one required input, inside the envelope too (the diagnostic names `value`).
widgets: [{ id: 'w', component: { type: 'metric-card', title: 'Revenue' } }],
};
// The tolerant zod face still accepts this document, through the widget
// `component` slot's `BaseSchema` arm (objectui#8344); that is the zod
// face's question, which this card leaves alone (objectui#8345).
// The tolerant zod face accepted this document through the widget
// `component` slot's `BaseSchema` arm until objectui#11709, which routes a
// `metric-card` there to the arm alone. The objectui#11709 enumeration pin
// below derives the omission from the arm's required `value`.
expect(valueless.type).toBe('dashboard');
expect(safeValidateSchema(valueless).success).toBe(false);
});

it('the same envelope with `value` is legal on both faces (the control)', () => {
Expand Down Expand Up @@ -323,12 +331,9 @@ describe('objectui#11467 — the component arm declares `MetricCard`\'s register
const envelope = (component: Record<string, unknown>) => ({ type: 'dashboard', widgets: [{ id: 'w', component }] });
const sideways = issues(envelope({ type: 'metric-card', value: '1', trend: 'sideways' }));
expect(sideways).toContain('"trend"');
// MEASURED LIMIT, recorded rather than ruled: the tolerant face keeps its
// `BaseSchema` fallback for any component node a `custom` widget carries, so
// a card that fails the arm still parses through it. The TypeScript union
// has the same corner (the fallback arm has no closed `type` to exclude it).
// Directly in `widgets[]` there is no such fallback, and both faces refuse.
expect(DashboardComponentZod.safeParse(envelope({ type: 'metric-card', value: '1', trend: 'sideways' })).success).toBe(true);
// The tolerant face refuses the same card since objectui#11709, and the
// TypeScript union already did. That is pinned once for every refusal the
// arm carries, by the objectui#11709 enumeration pin below.
// CONTROL — a non-card node in the slot is judged by `BaseSchema`, unchanged.
expect(issues(envelope({ type: 'chart' }))).toBe('');
expect(DashboardComponentZod.safeParse(envelope({ type: 'chart' })).success).toBe(true);
Expand Down Expand Up @@ -410,7 +415,7 @@ describe('objectui#4425 — `label` on the slot\'s `metric-card` is refused by n
expect([member, ok.type, onArm.type, inSlot.type]).toEqual([true, 'metric-card', 'metric-card', 'dashboard']);
});

it('in the legacy `component` envelope: the strict face and `tsc` refuse it; the tolerant face keeps its `BaseSchema` fallback', () => {
it('in the legacy `component` envelope: the strict face and `tsc` refuse it', () => {
const envelope = { type: 'dashboard', widgets: [{ id: 'w', component: { ...card, label: 'Total Revenue' } }] };
expect(StrictAnyComponentSchema.safeParse(envelope).success).toBe(false);
expect(JSON.stringify(flatIssues(StrictAnyComponentSchema.safeParse(envelope)))).toContain('Did you mean `label` → `title`?');
Expand All @@ -419,10 +424,101 @@ describe('objectui#4425 — `label` on the slot\'s `metric-card` is refused by n
// @ts-expect-error objectui#4425 — inside the envelope too
widgets: [{ id: 'w', component: { type: 'metric-card', value: '1', label: 'Total Revenue' } }],
};
// MEASURED LIMIT, the one the objectui#11467 block above records for `trend`: the tolerant
// face's `component` slot falls back to `BaseSchema` for any node a `custom` widget carries.
// Directly in `widgets[]` there is no such fallback, and every face refuses.
expect(DashboardComponentZod.safeParse(envelope).success).toBe(true);
// The tolerant face refuses it too since objectui#11709. It is one probe of
// the enumeration pin below, which derives `label` from the arm's members.
expect(typed.type).toBe('dashboard');
});
});

/* ── objectui#11709 — in the legacy `component` envelope, the arm alone judges a card ── */

/** A member of the arm, read the way the enumeration reads it: its own parse, and its optionality. */
type ArmMember = { safeParse: (value: unknown) => { success: boolean; error?: { issues: Issue[] } }; _zod: { optin?: string } };

/** A probe: the well-formed card with one member authored wrong (or omitted), and that member's own first issue. */
type Refusal = { key: string; authored: string; component: Record<string, unknown>; expected: Issue };

describe('objectui#11709 — the legacy `component` envelope judges a `metric-card` by the arm alone, at `objectui validate`', () => {
/** The widget's `component` slot, unwrapped from `.optional()`: the routed two-arm union. */
const slot = (DashboardWidgetZod.shape.component as unknown as { unwrap: () => { options: readonly unknown[] } }).unwrap();
const card = { type: 'metric-card', value: '$123,456' } as const;
const envelope = (component: Record<string, unknown>) => ({ type: 'dashboard', widgets: [{ id: 'w', component }] });

/**
* Every refusal the arm carries, DERIVED from the arm, never listed (objectui#11709's pin).
*
* For each member except the discriminator `type` (a different `type` routes to `BaseSchema`,
* by design), the probe authors the first of these JSON values that the member itself refuses.
* A tombstone refuses the first, an enum refuses the first as out of vocabulary, and a typed
* member refuses a value of another kind. A required member also gets an omission probe. A
* member that refuses none of them (`data`, typed `any`) yields no probe. So a refusal added
* to the arm later, whether a tombstone, an enum or a required input, is enumerated here with
* no edit. ⚠️ The bound, said per AGENTS.md #9: a refusal that only a value outside this list
* trips (a `.refine` on a member, say) is not derived.
*/
const CANDIDATES: readonly unknown[] = ['__not_a_member__', 42, true, null, {}, []];
const refusals: Refusal[] = Object.entries(slotArm.shape as unknown as Record<string, ArmMember>)
.filter(([key]) => key !== 'type')
.flatMap(([key, member]) => {
const out: Refusal[] = [];
const authored = CANDIDATES.find((value) => !member.safeParse(value).success);
if (authored !== undefined) {
out.push({ key, authored: JSON.stringify(authored), component: { ...card, [key]: authored }, expected: member.safeParse(authored).error!.issues[0] });
}
const omission = member.safeParse(undefined);
if (!omission.success) {
const component: Record<string, unknown> = { ...card };
delete component[key];
out.push({ key, authored: '(omitted)', component, expected: omission.error!.issues[0] });
}
return out;
});

it('the slot\'s first arm IS the arm `widgets[]` holds a card by: one arm, two slots', () => {
expect(slot.options[0]).toBe(slotArm);
});

it('LIT CONTROL — the derivation reaches the refusals the card named, and the required `value`', () => {
// Not the population (that is `refusals`): the floor that keeps a derivation reading
// nothing from passing with zero probes. A refusal retired from the arm turns this red.
const keys = new Set(refusals.map((r) => r.key));
for (const key of ['label', 'body', 'children', 'trend', 'value']) expect(keys, key).toContain(key);
expect(refusals.find((r) => r.key === 'value' && r.authored === '(omitted)')).toBeDefined();
});

it('CONTROL — a well-formed card, and the same card with `title`, parse on both faces with every key kept', () => {
for (const component of [card, { ...card, title: 'Total Revenue' }]) {
const result = safeValidateSchema(envelope(component));
expect(result.success, JSON.stringify(component)).toBe(true);
if (!result.success) continue;
const parsed = (result.data as { widgets: { component: Record<string, unknown> }[] }).widgets[0].component;
expect(parsed).toEqual(component);
expect(StrictAnyComponentSchema.safeParse(envelope(component)).success).toBe(true);
}
});

it('CONTROL — every other node is still the passthrough a `custom` widget\'s `component` carries', () => {
const doc = { type: 'dashboard', widgets: [{ id: 'w', type: 'custom', component: { type: 'text', content: 'hello', someProp: 1 } }] };
const result = safeValidateSchema(doc);
expect(result.success).toBe(true);
if (!result.success) return;
expect((result.data as { widgets: { component: Record<string, unknown> }[] }).widgets[0].component).toHaveProperty('someProp', 1);
});

it.each(refusals.map((r) => [`${r.key}: ${r.authored}`, r] as const))(
'%s — refused through the envelope with the member\'s own message, and judged by the arm alone',
(_name, refusal) => {
const doc = envelope(refusal.component);
const result = safeValidateSchema(doc);
expect(result.success).toBe(false);
const issues = flatIssues(result);
const at = ['widgets', 0, 'component', refusal.key, ...refusal.expected.path].join('.');
const own = issues.find((i) => i.path.join('.') === at && i.code === refusal.expected.code);
expect(own?.message, at).toBe(refusal.expected.message);
// No union issue at the slot: nothing but the arm judged the card, so no `BaseSchema`
// reading stands beside the arm's (before objectui#11709 one did, or it accepted).
expect(issues.filter((i) => i.path.join('.') === 'widgets.0.component' && i.code === 'invalid_union')).toEqual([]);
expect(StrictAnyComponentSchema.safeParse(doc).success).toBe(false);
},
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,11 @@ function readFace(nodes: Set<Z>, specOwned: Set<Z>): FaceReading {
// (objectui#8069: the form field-rule triad's blank refusal, a `.superRefine`
// over the shared predicate wire) stays on both sides and the differential
// still measures the arms alone — `z.union(options)` would drop `def.checks`,
// the loss `node-derivation.ts`'s `cloneWithDef` exists to prevent.
const twin = new (z.ZodUnion as any)({
// the loss `node-derivation.ts`'s `cloneWithDef` exists to prevent. It is rebuilt
// with ITS constructor for the same reason: the widget `component` slot routes a
// closed component type to its arm before the union runs (objectui#11709), and a
// plain `z.ZodUnion` twin would drop that routing and read it as an arm's move.
const twin = new ((u._zod as { constr?: unknown }).constr as any)({
...d,
options: (d.options as Z[]).map((o) => (isStrict(o) && isClosed(o) ? new (z.ZodObject as any)({ ...o._zod.def }) : o)),
});
Expand Down
64 changes: 63 additions & 1 deletion packages/types/src/zod/complex.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1354,6 +1354,60 @@ const DashboardWidgetSlotComponentSchema = BaseSchema.extend({
children: retirementTombstone(METRIC_CARD_NEITHER_CHANNEL),
});

/** The legacy envelope's `component` slot: the two arms, in the TypeScript twin's order. */
type WidgetComponentSlot = z.ZodUnion<readonly [typeof DashboardWidgetSlotComponentSchema, typeof BaseSchema]>;

/**
* objectui#11709: the constructor of the legacy envelope's `component` slot. It is a
* `z.union` of the same two arms, plus one routing step before the union runs. A node
* whose `type` the FIRST arm declares is judged by that arm alone. Any other node goes
* to the union exactly as before, so the accept set for every other `type` does not move.
*
* ## Why the slot routes
*
* A plain union tries each arm, and the first arm with no issues wins. The second arm
* is passthrough `BaseSchema`. It admits a node whatever its `type` says, so every
* `metric-card` the component arm refuses parsed through it. That was true for
* `label`, `children` and an out-of-enum `trend`, and for a card with no `value`.
* The arm's refusals therefore never reached `objectui validate`, while the strict face
* and `tsc` refused the same document. `body` was refused, but by both arms, so
* `objectui validate` printed `BaseSchema`'s message beside the arm's.
*
* ## Why this shape, measured against the other two
*
* - `z.discriminatedUnion('type', …)` cannot hold the second arm. `BaseSchema`'s `type`
* is an open string, which declares no discriminator value, so zod refuses the option
* (`Invalid discriminated union option at index "1"`), with or without `unionFallback`.
* - A `BaseSchema` arm that refuses `type: 'metric-card'` does refuse the card. But the
* union then fails on BOTH arms, and `objectui validate` prints each arm's issues,
* so the arm's message arrives beside a second, routing-only message from the fallback.
* - Routing gives the card to the arm, so the slot answers with the arm's own issues,
* at the arm's own paths. There is no union issue at `component`, and nothing from
* `BaseSchema`. That is the shape the same card already has directly in `widgets[]`.
*
* The routing reads the arm's own discriminator (its `propValues` for `type`, which is
* `DASHBOARD_COMPONENT_WIDGET_TYPES`), so it copies none of the arm's rules. A component
* type the arm later declares is routed with no edit here. This is the same mechanism as
* zod's `discriminatedUnion` with `unionFallback`. It is spelled here because zod's own
* version demands a declared value of every arm.
*
* ⚠️ The strict face derives this slot by cloning its def through this constructor
* (`../strict-authoring-face.ts`), so its twin routes the same way over the closed arms.
*/
const WidgetComponentSlotUnion = z.core.$constructor<WidgetComponentSlot>(
'ZodWidgetComponentSlotUnion',
(inst, def) => {
z.ZodUnion.init(inst, def);
const union = inst._zod.parse;
const [arm] = def.options;
inst._zod.parse = (payload, ctx) => {
const node = payload.value;
const type = typeof node === 'object' && node !== null ? (node as { type?: unknown }).type : undefined;
return arm._zod.propValues?.type?.has(type as string) ? arm._zod.run(payload, ctx) : union(payload, ctx);
};
},
);

/**
* Dashboard Widget Schema — DERIVED from `@objectstack/spec/ui`
* (objectstack#4115): every spec key flows in **by reference** via
Expand Down Expand Up @@ -1442,7 +1496,15 @@ export const DashboardWidgetSchema = specFieldsExcept(stripImportedDefaults(Spec
// authoring face refused all five as unrecognized (`component.value` and the rest,
// measured at `d93e53f5`); the arm admits them, judged by their members. Every
// other component node (a `custom` widget's) still parses through `BaseSchema`.
component: z.union([DashboardWidgetSlotComponentSchema, BaseSchema]).optional()
//
// objectui#11709: a node whose `type` the arm declares is judged by the arm ALONE,
// with no `BaseSchema` fallback, so each key the arm refuses is refused here too,
// with the arm's own message (see `WidgetComponentSlotUnion` above). The passthrough
// stays for every other node.
component: new WidgetComponentSlotUnion({
type: 'union',
options: [DashboardWidgetSlotComponentSchema, BaseSchema],
}).optional()
.describe('Widget Component (legacy format)'),
}).strict()
// ⭐ THE SPEC'S OBJECT-LEVEL CHECKS, re-attached (objectui#7715, ruling B1; objectui#11073).
Expand Down
Loading