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
36 changes: 36 additions & 0 deletions .changeset/6940-rowactions-boolean-mirror.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
'@object-ui/types': patch
---

`DataTableSchema.rowActions` validates as the boolean it has always been declared to be
(objectui#6940, maintainer ruling 2026-09-02, director seat summon #8, option A).

The hand-written zod mirror in `zod/data-display.zod.ts` declared
`rowActions: z.array(z.any()).optional()`. Every other face of the same key says
**boolean**: the TS declaration it mirrors (`rowActions?: boolean`), the renderer's
destructuring default (`rowActions = false`), its two truthiness gates and two
`colSpan` arithmetic sites, the registered authoring input
(`{ type: 'boolean', label: 'Show Row Actions' }`), `defaultProps: { rowActions: true }`,
and the renderer's own docblock example, which authors `"rowActions": true`. The mirror
was the single outlier — and the published one, so `safeValidateSchema` refused the
exact spelling the component's documentation, defaults and authoring UI all teach. Two
shipped `examples/schema-catalog` entries (`user-table.json`, `full-featured-table.json`)
failed validation for this and no other reason; both now validate **unchanged**.

**Patch, not minor or major, and the reasoning is the ruling's own:** no author can have
relied on an array value. The renderer never reads the array — it only truthiness-tests
the key — so the smallest zod-valid array, `[]`, rendered the actions column identically
to `true` (objectui#6318 measured both at 42 elements with the `Actions` header present,
against 39 with the key absent). An array authored here could therefore never have
carried meaning to any consumer: it either behaved exactly like `true` or, if empty,
still behaved exactly like `true`. Narrowing it takes away a spelling that was accepted
but inert, not one anything could have depended on.

A `boolean | array` union was considered and **not** taken: it would permanently accept
a shape the renderer cannot act on, which is the same second de-facto contract that the
array spelling already was.

The list view's same-named `rowActions` in `zod/objectql.zod.ts` — `z.array(z.string())`,
the legacy bare-name action list on `ObjectGridSchema` — is a **different key** that is
correct as it stands, is in parity with its own TS twin (`rowActions?: string[]`), and is
not touched.
124 changes: 124 additions & 0 deletions packages/types/src/__tests__/data-table-declared-keys-6882.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@
*/
import { describe, it, expect } from 'vitest';
import type { DataTableSchema } from '../data-display.js';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { DataTableSchema as DataTableMirror } from '../zod/data-display.zod.js';
import { ObjectGridSchema as ObjectGridMirror } from '../zod/objectql.zod.js';
import { safeValidateSchema } from '../zod/index.zod.js';

/**
* `T` with its string/number index signatures removed — the same shape
Expand Down Expand Up @@ -168,3 +174,121 @@ describe('objectui#6882 — DataTableSchema declares the two keys data-table rea
expect(typeof authored.renderCellEditor).toBe('function');
});
});

/* ══════════════════════════════════════════════════════════════════════════
* objectui#6940 — `DataTableSchema.rowActions` is a BOOLEAN on the mirror too
* ══════════════════════════════════════════════════════════════════════════
*
* Maintainer ruling 2026-09-02 (director seat, summon #8, verbatim
* 「7189 A 其他同意」), option A: the zod mirror in `../zod/data-display.zod.ts`
* becomes `z.boolean().optional()`, aligned with the TS declaration
* (`rowActions?: boolean`), the renderer's destructuring default
* (`rowActions = false`), the registered input (`type: 'boolean'`),
* `defaultProps` and the docblock example. Option B (a `boolean | array` union)
* was NOT taken: it would permanently accept a shape the renderer only
* truthiness-tests.
*
* ## Why the REFUSAL is the load-bearing half
*
* This is a NARROWING. A mirror that accepted both `true` and `[]` would
* satisfy a "`true` validates" assertion on its own — that assertion was green
* BEFORE this change for the array spelling and would stay green after a
* union. So the pin that carries the ruling's meaning is
* `_rowActionsArrayIsRefused` below, and it asserts not merely that the parse
* fails but that EVERY issue it raises is ON `rowActions` — a document refused
* for some unrelated reason would otherwise read as a passing narrowing pin.
*
* ## ⚠️ Two different keys are named `rowActions`
*
* `ObjectGridSchema.rowActions` (`../zod/objectql.zod.ts`, TS twin
* `../objectql.ts` `interface ObjectGridSchema`) is `z.array(z.string())` — the
* legacy bare-NAME action list, a genuinely different key that is correct as it
* stands and is NOT touched by this ruling. The last test below pins that
* separation, so a later sweep that "harmonises the two `rowActions`" turns red
* here instead of silently retyping a key no ruling covers.
*/

const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..');

/** The two entries the ruling names — they must validate UNCHANGED. */
const CATALOG_FIXTURES = [
'examples/schema-catalog/src/schemas/components-complex-data-table/user-table.json',
'examples/schema-catalog/src/schemas/components-complex-data-table/full-featured-table.json',
].map((rel) => ({ rel, abs: path.join(REPO_ROOT, rel) }));

/** A minimal document that is valid except for whatever `rowActions` is set to. */
const baseDoc = {
type: 'data-table',
columns: [{ header: 'Name', accessorKey: 'name' }],
data: [] as unknown[],
};

describe('objectui#6940 — the `rowActions` mirror is the declared boolean', () => {
it('`rowActions: true` validates — the spelling the renderer, inputs and docs all teach', () => {
const parsed = DataTableMirror.safeParse({ ...baseDoc, rowActions: true });
expect(parsed.success ? null : parsed.error.issues).toBe(null);
});

it('`rowActions: false` validates too — the key is a boolean, not a truthy-only flag', () => {
const parsed = DataTableMirror.safeParse({ ...baseDoc, rowActions: false });
expect(parsed.success ? null : parsed.error.issues).toBe(null);
});

it('⭐ `rowActions: []` is REFUSED, and refused ON `rowActions`', () => {
// `[]` was the SMALLEST value the pre-#6940 mirror accepted, and #6318
// measured that it renders the actions column identically to `true`
// (because `[]` is truthy) — so it made documents say something the
// renderer cannot act on. Narrowing is the whole point of the ruling;
// this is where that is proved.
const parsed = DataTableMirror.safeParse({ ...baseDoc, rowActions: [] });
expect(parsed.success, '`rowActions: []` still validates — the mirror did not narrow').toBe(false);

if (!parsed.success) {
const paths = parsed.error.issues.map((issue) => issue.path.join('.'));
// Every issue must be about `rowActions`. Without this, a document
// rejected for an unrelated reason would satisfy the assertion above.
expect(paths, `refused, but not on rowActions: ${JSON.stringify(paths)}`).toEqual(['rowActions']);
}
});

it('the published `safeValidateSchema` surface moves with it, in both directions', () => {
// The ruling is stated about THIS entry point ("changes what
// `safeValidateSchema` accepts on a published package"), and it is a
// `z.union` — so the refusal has to be measured here too rather than
// inferred from the member mirror: a sibling union member accepting the
// document would leave the published surface unchanged.
expect(safeValidateSchema({ ...baseDoc, rowActions: true }).success).toBe(true);
expect(safeValidateSchema({ ...baseDoc, rowActions: [] }).success).toBe(false);
});

it('the two schema-catalog entries this card was filed over are on disk', () => {
// Asserted before anything reads them: a path that silently resolved to
// nothing would make the next test a vacuous pass.
for (const { rel, abs } of CATALOG_FIXTURES) {
expect(fs.existsSync(abs), `fixture not found at ${rel}`).toBe(true);
}
});

it('…and both validate UNCHANGED — they author `rowActions: true` and always did', () => {
for (const { rel, abs } of CATALOG_FIXTURES) {
const doc = JSON.parse(fs.readFileSync(abs, 'utf8')) as Record<string, unknown>;
expect(doc.rowActions, `${rel} no longer authors the boolean this pin was written for`).toBe(true);

const parsed = safeValidateSchema(doc);
expect(parsed.success ? null : parsed.error.issues, `${rel} does not validate`).toBe(null);
}
});

it('the list view’s same-named `rowActions` is a DIFFERENT key and still takes `string[]`', () => {
// `ObjectGridSchema.rowActions` is the legacy bare-NAME action list. The
// ruling leaves it alone, and the mirror-parity ratchet agrees it is in
// parity with its TS twin (`rowActions?: string[]`) — it appears in
// NEITHER of that file's drift ledgers. Pinned here so the two keys are not
// later "harmonised" on the strength of sharing a name.
const grid = { type: 'object-grid', objectName: 'accounts', rowActions: ['edit', 'delete'] };
expect(ObjectGridMirror.safeParse(grid).success).toBe(true);

// …and the boolean this card installs on the OTHER key is not valid here.
expect(ObjectGridMirror.safeParse({ ...grid, rowActions: true }).success).toBe(false);
});
});
22 changes: 16 additions & 6 deletions packages/types/src/__tests__/zod-mirror-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@
* already pins equal to `keyof Declared`. Nothing asserts it against a written
* number, so this line is prose and can rot; the pin that cannot is the one
* comparing the two halves to each other.
* - **39 entries** in `KnownDrift`, **56 keys** across them. It was 12 / 17 until
* - **39 entries** in `KnownDrift`, **55 keys** across them — 56 until objectui#6940
* REPAIRED `DataTableSchema.rowActions` (the entry kept its other four keys, so
* the entry count did not move). It was 12 / 17 until
* objectui#6124 added the RUNTIME-SLOT class (28 pairs touched, 35 keys) — see
* the class note inside the ledger, above `ButtonSchema` — 36 / 52 until
* objectui#6576 minted `ObjectDataTableSchema` with one such arm (`onRowClick`),
Expand Down Expand Up @@ -726,10 +728,18 @@ interface KnownDrift {
*/
'crud.zod.ts#DetailSchema': 'onBack';
/**
* `rowActions` — DISJOINT: TS declares `rowActions?: boolean` (show the column or
* not), the mirror declares `any[]` (the actions themselves). One of the two is
* dead; which is a ruling. (`selectable` was a second drifted key here until
* objectui#5927 widened the mirror to `boolean | 'single' | 'multiple'` —
* `rowActions` was the FIFTH key here until objectui#6940 settled the ruling
* this entry was explicitly waiting on. It read: DISJOINT — TS declares
* `rowActions?: boolean` (show the column or not), the mirror declared
* `any[]` (the actions themselves); one of the two is dead, which is a
* ruling. The maintainer ruled the TS side live (2026-09-02, director seat
* summon #8, option A): the renderer only truthiness-tests the key, so the
* `any[]` face was the dead one, and the mirror became
* `z.boolean().optional()`. The pair is now IN PARITY on that key, so it left
* this entry — this ledger fails on a repair exactly as it fails on new
* drift, which is why correcting this line was part of that change and not
* optional. (`selectable` was likewise a drifted key here until objectui#5927
* widened the mirror to `boolean | 'single' | 'multiple'` —
* `resolveSelectionMode` in `renderers/complex/data-table.tsx` implements
* `'single'` as a real mode.)
*
Expand All @@ -738,7 +748,7 @@ interface KnownDrift {
* `schema.onSelectionChange(selectedData)`, …), so the TS side keeps them callable
* and the mirror refuses them by name.
*/
'data-display.zod.ts#DataTableSchema': 'rowActions' | 'onRowEdit' | 'onRowDelete' | 'onSelectionChange' | 'onColumnsReorder';
'data-display.zod.ts#DataTableSchema': 'onRowEdit' | 'onRowDelete' | 'onSelectionChange' | 'onColumnsReorder';
/** RUNTIME SLOT (objectui#6124): the `accordion` renderer spreads leftover props onto the Radix `Accordion` root, where `onValueChange` is a real prop. */
'disclosure.zod.ts#AccordionSchema': 'onValueChange';
/** RUNTIME SLOT (objectui#6124): the `collapsible` renderer spreads leftover props onto the Radix `Collapsible` root. */
Expand Down
2 changes: 1 addition & 1 deletion packages/types/src/zod/data-display.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ export const DataTableSchema = BaseSchema.extend({
selectable: z.union([z.boolean(), z.enum(['single', 'multiple'])]).optional().describe('Enable row selection — `true`/`multiple` = multi-select, `single` = replace-on-select with no select-all'),
sortable: z.boolean().optional().describe('Enable sorting'),
exportable: z.boolean().optional().describe('Enable data export'),
rowActions: z.array(z.any()).optional().describe('Row action buttons'),
rowActions: z.boolean().optional().describe('Show the row actions column (edit/delete) — mirrors the boolean the renderer truthiness-tests (objectui#6940)'),
resizableColumns: z.boolean().optional().describe('Allow column resizing'),
reorderableColumns: z.boolean().optional().describe('Allow column reordering'),
onRowEdit: handlerKeyRefusal('onRowEdit', 'runtime-slot', 'Row edit handler'),
Expand Down
Loading