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
71 changes: 71 additions & 0 deletions .changeset/7804-list-view-handler-slots-declared.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
---
'@object-ui/types': minor
---

Declare the five handler keys the `'list-view'` renderer reads (objectui#7804,
the `ListViewSchema` slice).

The zod arm `type: 'list-view'` selects now declares `onAddRecord`,
`onBulkAction`, `onDensityChange`, `onNavigate` and `onPageSizeChange` as
objectui#6124 RUNTIME SLOTS: a named refusal on the JSON face, a callable twin
on the TypeScript face.

**Breaking, and measured.** `BaseSchema` ends `.passthrough()`, so a key an arm
does not declare is not refused — it stops being judged and the value is KEPT.
`SchemaRenderer` then spreads every non-metadata top-level key of the node into
the component props bag, so an authored value reaches the read site. Measured on
the unmodified arm: each of the five parsed GREEN with `{"action":"toast"}`
surviving into the parsed output, while `ListView` went on reading and INVOKING
it — `props.onAddRecord?.()` behind the toolbar's add-record button,
`props.onBulkAction?.(action, rows)` behind a bulk-action button,
`props.onPageSizeChange(newSize)` on the pager's `select`, and
`schema.onNavigate` / `schema.onDensityChange` handed to `useNavigationOverlay`
and `useDensityMode`. After this change all five are refused BY NAME with the
objectui#6124 guidance (issue `code: 'custom'` at the key's own path) and the
message points at the node-type spelling. `onRowClick`, still undeclared on the
same arm, is still accepted and still KEPT on the same document — the control
proving the probe distinguishes a refusal from a parser rejecting everything.

A version shipped as `minor` because this package ships inside the `fixed`
group `.changeset/config.json` enumerates, where any `major` would carry every
member with it, so `major` is unavailable
(`scripts/check-changeset-no-major.mjs`); the accept-set move is the breaking
part.

**The TypeScript face is narrowed too, and only where it should be.** Unlike
every earlier slice of this card, this arm FEEDS its own declared type:
`ListViewSchema` is `z.input` of the mirror intersected with
`ListViewRuntimeProps`. A refusal arm's `z.input` is `never | undefined`, which
ANDs a runtime declaration down to `undefined` — so declaring the five would
have silently killed `onNavigate` and `onDensityChange` on the TypeScript face
while every gate stayed green. The intersection now gives the runtime half
precedence (`ListViewAuthored`), so:

- `ListViewSchema['onNavigate']` and `['onDensityChange']` are unchanged —
still the function types `ListViewRuntimeProps` declares, still supplied on
the node by hosts such as `@object-ui/app-shell`'s `ObjectView`.
- `ListViewSchema['onAddRecord']`, `['onBulkAction']` and `['onPageSizeChange']`
are now DECLARED on `ListViewRuntimeProps` with the signatures `ListViewProps`
in `@object-ui/plugin-list` already carried, where before they were typed only
by `BaseSchema`'s passthrough index signature — `unknown`, a declaration
nobody wrote and nobody can read. `ListView` reads all three off its props
bag, and a host fills that bag either by passing the React prop or by putting
the key on the node, where `SchemaRenderer` spreads it in; this declaration is
the second path's contract. Same repair as the `ObjectGallerySchema` pair one
slice earlier, for the same spread.

**A published interface therefore WIDENS as well as narrowing.** Three members
are added to `ListViewRuntimeProps`. Nothing that compiled before stops
compiling — `unknown` accepted any host handler and the declared signatures
accept the ones `ListViewProps` already held hosts to — but the surface is
larger, and it is stated here rather than left to be discovered.

**Migration.** Nothing in the corpus has to change: no authored `'list-view'`
document in this repository, its examples or its docs writes any of the five —
they were only ever reachable as host-supplied functions. A React host keeps
supplying them exactly as before. A document that *did* author one was never
running anything: it was being handed an object where a function was expected.

Per key, not per prefix: the five reach the renderer on two different faces —
two off the node, three off the props bag — and each disposition was assigned
from its own channel, not from its siblings'.
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,14 @@ const ABSENCES: Record<string, Absence> = {
// ── The runtime-only half of the intersection ─────────────────────────────
onNavigate: { kind: 'host-runtime', reason: 'Host callback. This host wires record navigation through the `onRowClick` prop on the `<ListView>` element instead; a view record cannot carry a function.' },
refreshTrigger: { kind: 'host-runtime', reason: 'Host refresh counter, supplied by the caller; not view metadata.' },
// ⭐ objectui#7804 declared these three on `ListViewRuntimeProps`, so they
// entered this census's population with that slice and owe an answer here.
// All three are host callbacks `ListView` reads off its PROPS bag; this host
// supplies none of them and a view RECORD cannot carry a function, so there is
// no rung to add — the absence is the whole of their contract on this path.
onAddRecord: { kind: 'host-runtime', reason: 'Host callback for the toolbar "+ New" affordance. This host routes record creation through its own action layer rather than through ListView, and a view record cannot carry a function.' },
onBulkAction: { kind: 'host-runtime', reason: 'Host callback for non-delete bulk actions. This host wires bulk delete through the `onBulkDelete` prop on the `<ListView>` element and offers no other bulk action here; a view record cannot carry a function.' },
onPageSizeChange: { kind: 'host-runtime', reason: 'Host callback for the pager\'s page-size select. This host lets ListView keep page size as session state rather than persisting it, the same posture as `onFilterChange` (objectui#4155); a view record cannot carry a function.' },
};

// ---------------------------------------------------------------------------
Expand Down
179 changes: 179 additions & 0 deletions packages/types/src/__tests__/list-view-handler-slots-7804.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/**
* 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#7804 — the `list-view` arm declares the five handler keys its
* registered renderer reads, and the TypeScript face survives the declaration.
*
* Two halves, one file, because on THIS arm they are one change. Every earlier
* slice of this card drained a plain `interface X extends BaseSchema`, whose
* TypeScript face is hand-written and therefore untouched by what the mirror
* declares. `ListViewSchema` is not that shape: it is `z.input` of the mirror
* (`ListViewInferred`) intersected with `ListViewRuntimeProps`, so a refusal
* arm — whose `z.input` is `never | undefined` — ANDs a runtime declaration
* down to `undefined`. Measured before the precedence was written:
* `ListViewSchema['onNavigate']` resolved to `undefined`, down from
* `((recordId: string | number, action?: string) => void) | undefined`, and
* NOTHING went red, because `undefined` is assignable to every optional
* callback parameter the reads hand it.
*
* ⇒ the type half below is not decoration. It is the only thing that fails
* when someone collapses `ListViewAuthored` back to a bare intersection, and
* it is compiled — `tsconfig.test.json` type-checks every `*.test.ts` in this
* package, chained from the package's `type-check` script.
*
* ⛔ What this file does NOT assert: that the keys are unreachable. They are
* RUNTIME SLOTS. `onNavigate` / `onDensityChange` reach `ListView` off the node
* (`schema.onX`); the other three reach it off the PROPS bag, which a host fills
* either by passing the React prop `ListViewProps` declares or by putting the
* key on the NODE, where `SchemaRenderer` spreads it in. A host still supplies
* all five; what is refused is AUTHORING one as JSON, which could only ever hand
* a call site a plain object where it expects a function.
*/
import { describe, it, expect } from 'vitest';
import { ListViewSchema as ListViewMirror } from '../zod/objectql.zod.js';
import type { ListViewSchema, ListViewRuntimeProps } from '../objectql.js';

/** The five rows this slice drained from `KNOWN_UNDECLARED_READS`. */
const DECLARED_REFUSALS = [
'onAddRecord',
'onBulkAction',
'onDensityChange',
'onNavigate',
'onPageSizeChange',
] as const;

/**
* A handler key this arm still does not declare — the LIVE CONTROL.
*
* It is the exact state all five above were in before this slice: undeclared,
* so `BaseSchema.passthrough()` does not refuse it — it stops judging it and
* KEEPS the value. Asserting it is still accepted in the same pass is what
* makes the five refusals a reading rather than a claim about a parser that
* might simply be rejecting everything.
*
* ⚠️ If a later slice declares this key, this control dies silently — so the
* first assertion below fails loudly instead, telling the next author to pick
* a new one rather than lose the control.
*/
const STILL_UNDECLARED = 'onRowClick';

/** A minimal node the arm accepts, so a failure can only come from the key under test. */
function node(extra: Record<string, unknown>) {
return { type: 'list-view', objectName: 'accounts', ...extra };
}

/** The authored value shape that motivates the whole card: an action object, not a function. */
const AUTHORED = { action: 'toast' } as const;

describe('objectui#7804 — the `list-view` arm refuses its five handler keys BY NAME', () => {
it('the control key is genuinely still undeclared on this arm', () => {
expect(Object.keys((ListViewMirror as unknown as { shape: Record<string, unknown> }).shape))
.not.toContain(STILL_UNDECLARED);
});

it.each(DECLARED_REFUSALS)('refuses `%s` at its own path, with the #5099 `custom` code', (key) => {
const result = ListViewMirror.safeParse(node({ [key]: AUTHORED }));

expect(result.success).toBe(false);
const issues = result.success ? [] : result.error.issues;
const own = issues.filter((issue) => issue.path.length === 1 && issue.path[0] === key);
expect(own).toHaveLength(1);
expect(own[0].code).toBe('custom');
// The message names the key, so the issue is addressed even when it is read
// without its path — the one contract `handlerKeyRefusal` composes.
expect(own[0].message).toContain(`\`${key}\``);
});

it('a LIVE FUNCTION is refused too — the refusal is about the key, not the value', () => {
for (const key of DECLARED_REFUSALS) {
const result = ListViewMirror.safeParse(node({ [key]: () => undefined }));
expect(result.success, `${key} should refuse a function value as well`).toBe(false);
}
});

it('⭐ CONTROL — the still-undeclared key is ACCEPTED and its value is KEPT', () => {
const result = ListViewMirror.safeParse(node({ [STILL_UNDECLARED]: AUTHORED }));

expect(result.success).toBe(true);
// Not merely accepted: the passthrough KEEPS it, which is what carried an
// authored object all the way to a call site expecting a function.
const parsed = (result.success ? result.data : {}) as Record<string, unknown>;
expect(parsed[STILL_UNDECLARED]).toEqual(AUTHORED);
});
});

describe('objectui#7804 — the TypeScript face survives the declaration', () => {
it('compiles: the assertions in this file are the check, and tsc is what runs them', () => {
// The work is in the type-level block below; this keeps the suite honest
// about there being a runtime no-op here.
expect(typeof TYPE_PINS).toBe('object');
});
});

/**
* ⛔ TYPE-LEVEL PINS — these fail at `tsc`, not at runtime.
*
* Each one was `undefined` (or, for `objectName`, `unknown`) under a candidate
* this slice measured and rejected; see `ListViewAuthored` in `../objectql.ts`
* for both readings.
*/
const TYPE_PINS = {
/** The node-read runtime slots stay CALLABLE — what `'runtime-slot'` promises. */
onNavigate: ((recordId: string | number, action?: string) => {
void recordId;
void action;
}) satisfies NonNullable<ListViewSchema['onNavigate']>,
onDensityChange: ((mode: 'compact' | 'comfortable' | 'spacious') => {
void mode;
}) satisfies NonNullable<ListViewSchema['onDensityChange']>,
/** The non-handler runtime prop rides the same precedence. */
refreshTrigger: 1 satisfies NonNullable<ListViewSchema['refreshTrigger']>,
/**
* ⭐ THE `Omit` REGRESSION PIN. `Omit<ListViewInferred, keyof ListViewRuntimeProps>`
* keeps ONLY the index signature `BaseSchema.passthrough()` puts on the
* inferred type, so every declared member collapses to `unknown` — measured:
* `objectName` read `unknown` instead of `string`. This is that measurement,
* kept where it fails.
*/
objectName: 'accounts' satisfies ListViewSchema['objectName'],
} as const;

/**
* The three PROPS-half slots stay CALLABLE on the node type too — the second
* supply path (`SchemaRenderer` spreading a node key into the props bag) is what
* this declaration contracts, and it is exactly what the refusal arm would have
* killed without the precedence.
*/
const propsHalf = {
onAddRecord: (() => undefined) satisfies NonNullable<ListViewSchema['onAddRecord']>,
onBulkAction: ((action: string, records: unknown[]) => {
void action;
void records;
}) satisfies NonNullable<ListViewSchema['onBulkAction']>,
onPageSizeChange: ((size: number) => {
void size;
}) satisfies NonNullable<ListViewSchema['onPageSizeChange']>,
} as const;
void propsHalf;

/**
* ⛔ And the precedence is keyed on `ListViewRuntimeProps`' own member list, so
* a sixth refusal arm added to the mirror without a matching declaration here
* would go back to resolving as `undefined`. This is that list, stated where it
* fails if a member leaves it.
*/
const runtimeKeys: Array<keyof ListViewRuntimeProps> = [
'onNavigate',
'onDensityChange',
'refreshTrigger',
'onAddRecord',
'onBulkAction',
'onPageSizeChange',
];
void runtimeKeys;
57 changes: 56 additions & 1 deletion packages/types/src/__tests__/list-view-spec-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,30 @@ const SANCTIONED_LOCAL = new Set<string>([
'options',
]);

/**
* A THIRD category, and deliberately not a row in `SANCTIONED_LOCAL`: members
* that are not authorable fields at all (objectui#7804).
*
* Each of these is a `handlerKeyRefusal` arm — a key a REGISTERED renderer
* reads off the authored document, declared here only so that
* `BaseSchema.passthrough()` stops KEEPING an authored value and the author
* gets a refusal that names the key. Neither branch the docblock above offers
* fits one: promoting it into `@objectstack/spec` would ask the protocol to
* declare a key JSON cannot express, and calling it a "genuine objectui-only
* extension" would say the arm accepts something. It accepts nothing.
*
* ⚠️ Membership here is still a deliberate act — and it is CHECKED. The test
* below refuses a member that is not actually a refusal arm, so this set
* cannot be used to park a real authorable field outside the drift guard.
*/
const HANDLER_KEY_REFUSALS = new Set<string>([
'onAddRecord',
'onBulkAction',
'onDensityChange',
'onNavigate',
'onPageSizeChange',
]);

describe('ListView spec parity (#2231 drift guard)', () => {
it('covers every @objectstack/spec ListView field (spec cannot grow a field objectui ignores)', () => {
// Fails when the spec adds a field that objectui neither imports nor envelope-owns —
Expand All @@ -126,11 +150,42 @@ describe('ListView spec parity (#2231 drift guard)', () => {
it('declares no objectui-only field outside the sanctioned-local set', () => {
// Fails when a new objectui-only field is added without deciding local-vs-upstream.
const rogue = [...ouiKeys].filter(
(k) => !specShape[k] && !ENVELOPE.has(k) && !SANCTIONED_LOCAL.has(k),
(k) =>
!specShape[k] &&
!ENVELOPE.has(k) &&
!SANCTIONED_LOCAL.has(k) &&
!HANDLER_KEY_REFUSALS.has(k),
);
expect(rogue).toEqual([]);
});

it('every HANDLER_KEY_REFUSALS member really refuses — the set cannot hide an authorable field', () => {
const node = (extra: Record<string, unknown>) => ({
type: 'list-view',
objectName: 'accounts',
...extra,
});

// ⭐ CONTROL first: the same probe on a sanctioned-local key that IS
// authorable must be accepted, or the loop below proves nothing.
expect(OuiListViewSchema.safeParse(node({ viewType: 'grid' })).success).toBe(true);

for (const key of HANDLER_KEY_REFUSALS) {
expect(ouiKeys.has(key), `${key} is listed but not declared on the arm`).toBe(true);
// Both faces of "accepts nothing": the authored action object this card
// exists for, and a live function, which is the only value a host could
// ever have meant.
expect(
OuiListViewSchema.safeParse(node({ [key]: { action: 'toast' } })).success,
`${key} must refuse an authored action object`,
).toBe(false);
expect(
OuiListViewSchema.safeParse(node({ [key]: () => undefined })).success,
`${key} must refuse a function value too`,
).toBe(false);
}
});

it('preserves the component discriminator + required objectName', () => {
const bad = OuiListViewSchema.safeParse({ objectName: 'accounts' }); // no type
expect(bad.success).toBe(false);
Expand Down
Loading
Loading