diff --git a/.changeset/6459-grid-data-table-schema-slot.md b/.changeset/6459-grid-data-table-schema-slot.md new file mode 100644 index 0000000000..fc46f0ffec --- /dev/null +++ b/.changeset/6459-grid-data-table-schema-slot.md @@ -0,0 +1,28 @@ +--- +'@object-ui/plugin-grid': minor +--- + +feat(plugin-grid): type the data-table schema slot ObjectGrid fills + +`const dataTableSchema: any` becomes `ObjectGridDataTableSchema`, and +`buildGroupTableSchema`'s return carries the same annotation, so the ~46 keys +this grid writes into the `data-table` slot are finally checked against the +slot's declaration — the receiver half of the seam whose producer half #6004 +typed. The `(dataTableSchema.columns as any[])` cast in the grouped writer +drops with it. + +The annotation is not a bare `DataTableSchema`, and that is the substance: +measured on this program, a bare annotation with an undeclared bogus key +written longhand in the fresh literal compiles with **zero** diagnostics, +because `BaseSchema`'s `[key: string]: any` index signature makes every key a +member — excess-property checking never has a non-member to refuse. +`ObjectGridDataTableSchema` derives from `DataTableSchema` by stripping the +index signature (never by hand-listing members), which makes the same probe go +red (TS2353) at both writer literals — shown able to fail before being claimed +as coverage, per the #6004 rule. + +Two schema-level keys the grid passes are undeclared on `DataTableSchema` and +HELD at the seam with measured live readers in `data-table`: +`renderCellEditor` and `cellClassName`. Whether `DataTableSchema` should +declare them is filed for a ruling — nothing is declared on or retired from +`@object-ui/types` here. diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index bc9f08fe0f..1e2c26da29 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -641,6 +641,107 @@ export type ObjectGridColumn = & ObjectGridRetiredOptionsTombstone & { [K in RetiredListColumnKey]?: never }; +/** + * ⭐ THE SCHEMA SLOT THIS GRID FILLS (objectui#6459) — the receiver half of the + * seam whose producer half #6004 typed. `dataTableSchema` below was + * `const dataTableSchema: any`, so the ~46 keys assembled there were checked + * against nothing, while the `DataTableSchema` this file imports went unused as + * that object's annotation. + * + * ## Why the fix is not `: DataTableSchema` — measured before choosing this shape + * + * objectui#6459's sizing note predicted a bare annotation would be inert + * because `buildGroupTableSchema` re-emits the value through a spread and + * excess-property checking is a freshness check. Measured here (on + * `38a123cac`), the truth is stronger and the spread is not even needed: + * + * 1. `const dataTableSchema: DataTableSchema = {`, everything else unchanged, + * PLUS an undeclared `bogusKeyForProbe6459: true` WRITTEN OUT LONGHAND in + * the (fresh) literal → `tsc --noEmit` exit 0, ZERO diagnostics. + * + * The reason is `BaseSchema`'s `[key: string]: any` index signature, which + * `DataTableSchema` inherits: under an index signature EVERY key is a member, + * so excess-property checking never has a non-member to refuse — at a fresh + * literal, through a spread, anywhere. It is the terminal case of the rule the + * `options` tombstone above records ("a pin enforced by a key's non-membership + * silently stops enforcing the moment the key becomes a member"): with an + * index signature there is no non-membership to enforce with, ever. + * + * `RemoveIndexSignature` strips it — DERIVED from `DataTableSchema`, never a + * hand-copied member list, so a member added there tomorrow flows in on its + * own and two enumerations of one vocabulary never exist. With the signature + * gone, the same probe goes red (TS2353 naming the key), measured at both + * annotated literals — the flat one and `buildGroupTableSchema`'s return. + * + * ## What the instrument is, and is not (pinned in dataTableSchemaSlot-6459.test.ts) + * + * Both writers of this type are object literals sitting DIRECTLY in annotated + * positions (a `const` initializer; an annotated arrow's parenthesized return), + * so unlike `generateColumns()` — where `.map()` laundered freshness away and + * only `?: never` tombstones could bite — excess-property checking is live + * here for longhand keys, and the group literal's spread source is itself the + * checked `const`, so every key entering that spread was already refused or + * admitted at ITS literal. What this shape does NOT do is refuse an undeclared + * key riding a NON-FRESH value assigned into the slot (assignability admits + * extra keys; that is structural typing, not a bug here) — per-key `?: never` + * tombstones remain the instrument for a key RETIRED by ruling, but an open + * census cannot be tombstoned, because a tombstone needs the key's name. + * + * ## The census this annotation surfaced (the substance, per the card) + * + * Diffing the 46 keys the flat literal writes (plus the 8 the group literal + * re-writes) against `DataTableSchema` + `BaseSchema` declared members leaves + * exactly TWO undeclared keys — the card's speculative list (`pagination`, + * `manualPagination`, `rowCount`, `frozenColumns`, `singleClickEdit`, + * `selectionResetKey`, `disableInnerScroll`, `borderless`) has since been + * declared on `DataTableSchema`, and only these survive: + * + * - `renderCellEditor` — HELD. Live: `data-table.tsx` reads it via its own + * `(schema as any).renderCellEditor` cast and hands cell editing to the + * returned widget; absent, cells fall back to the built-in text/number/date + * inputs. Undocumented at schema level. Whether `DataTableSchema` should + * declare it is a `packages/types` (human-floor) ruling, not this card's — + * declared here at the seam meanwhile, so the hold is visible. + * - `cellClassName` — HELD. Live: `data-table.tsx` destructures it off the + * schema and folds it into every body cell's `className` (this is the + * SCHEMA-level key; the column-level twin IS declared, on `TableColumn`). + * Absent, the grid's row-height density styling stops reaching cells. + * Undocumented at schema level; same pending ruling as above. + * + * ⛔ Do not "fix" either hold by declaring the key on `DataTableSchema` as a + * rider — that package is published surface with its own review floor, and the + * census above is filed for a ruling on exactly that question. + */ +type RemoveIndexSignature = { + [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K]; +}; + +/** `DataTableSchema`'s DECLARED members only — the index signature stripped. */ +export type DeclaredDataTableSchema = RemoveIndexSignature; + +/** + * The undeclared-but-live SCHEMA-level keys this grid holds at the seam — the + * schema-slot sibling of `ObjectGridColumnHolds`. Shapes mirror the CONSUMER's + * reads in `data-table.tsx`, not what this file happens to pass. + */ +export type ObjectGridDataTableSchemaHolds = { + /** HELD (objectui#6459) — `data-table` calls it to render a host cell editor. */ + renderCellEditor?: (ctx: { + column: any; + row: any; + value: any; + stage: (v: any) => void; + commit: (v?: any) => void; + cancel: () => void; + }) => React.ReactNode; + /** HELD (objectui#6459) — `data-table` folds it into every body cell's class. */ + cellClassName?: string; +}; + +/** What this grid is allowed to write into the `data-table` schema slot. */ +export type ObjectGridDataTableSchema = + DeclaredDataTableSchema & ObjectGridDataTableSchemaHolds; + /** The row heights this grid styles — the five `RowHeight` values the spec admits. */ type RowHeightMode = 'compact' | 'short' | 'medium' | 'tall' | 'extra_tall'; @@ -3288,7 +3389,7 @@ export const ObjectGrid: React.FC = ({ ? hostOnSearchChange : setSearchTerm; - const dataTableSchema: any = { + const dataTableSchema: ObjectGridDataTableSchema = { type: 'data-table', caption: schema.label || schema.title, columns: orderedColumns, @@ -3487,7 +3588,7 @@ export const ObjectGrid: React.FC = ({ } /** Build a per-group data-table schema (inherits everything except data & pagination). */ - const buildGroupTableSchema = (groupRows: any[]) => ({ + const buildGroupTableSchema = (groupRows: any[]): ObjectGridDataTableSchema => ({ ...dataTableSchema, caption: undefined, data: groupRows, @@ -3504,8 +3605,10 @@ export const ObjectGrid: React.FC = ({ // Frozen columns rely on per-table sticky offsets that don't compose with // the shared scroll container; disable them in grouped mode. frozenColumns: 0, - // Pin explicit, shared widths so columns align across all groups. - columns: (dataTableSchema.columns as any[]).map((c: any) => ({ + // Pin explicit, shared widths so columns align across all groups. No cast: + // `dataTableSchema` is typed now, so `.columns` is `TableColumn[]` (the + // `as any[]` existed only because the surrounding value was `any`, #6459). + columns: dataTableSchema.columns.map((c) => ({ ...c, width: groupedColumnWidths[c.accessorKey] ?? c.width, })), diff --git a/packages/plugin-grid/src/__tests__/dataTableSchemaSlot-6459.test.ts b/packages/plugin-grid/src/__tests__/dataTableSchemaSlot-6459.test.ts new file mode 100644 index 0000000000..8039695e19 --- /dev/null +++ b/packages/plugin-grid/src/__tests__/dataTableSchemaSlot-6459.test.ts @@ -0,0 +1,167 @@ +/** + * 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#6459 — the data-table SCHEMA SLOT ObjectGrid fills (the receiver + * half of the seam whose producer half #6004 typed). + * + * ⚠️ THE DEFECT IS TYPE-LEVEL, SO A RENDERING TEST IS BLIND TO IT — same as + * `columnEmitBoundary-6004.test.ts` next door, and for the same reason: a grid + * renders identically with `const dataTableSchema: any`. What can fail is a + * COMPILE, so these pins are compile-time and this file's value is that + * `tsc -p tsconfig.test.json` reads it (verified with `--listFiles`, not + * assumed — the package build's program excludes `__tests__`). + * + * The mechanism pinned here is DIFFERENT from #6004's. There, `.map()` + * laundered freshness away and only `?: never` tombstones could refuse; here + * both writers are literals sitting DIRECTLY in annotated positions, so + * excess-property checking is the live instrument — PROVIDED the type has no + * index signature. `BaseSchema`'s `[key: string]: any` (which `DataTableSchema` + * inherits) makes every key a member, and a check that refuses non-members has + * nothing to refuse when non-membership cannot exist. `RemoveIndexSignature` in + * `ObjectGrid.tsx` is what turns the annotation from inert to able-to-fail, + * and the pins below hold each half of that claim separately. + */ +import { describe, it, expect } from 'vitest'; +import type { DataTableSchema, TableColumn } from '@object-ui/types'; +import type { + DeclaredDataTableSchema, + ObjectGridDataTableSchema, +} from '../ObjectGrid'; + +/** Compile-time truth assertion, erased at runtime — only `tsc` checks these. */ +type Expect = T; + +const columns: TableColumn[] = [{ header: 'Name', accessorKey: 'name' }]; + +describe('objectui#6459 — the schema slot annotation is an instrument, not a decoration', () => { + /** + * ⭐ THE ROOT CAUSE, PINNED: a bare `DataTableSchema` annotation refuses + * NOTHING. This assignment MUST COMPILE — the bogus key is written out + * longhand in a FRESH literal, the strongest position excess-property + * checking ever has, and the inherited `[key: string]: any` still admits it. + * Measured on the real seam before the fix was shaped (`tsc --noEmit` + * exit 0, zero diagnostics, with `bogusKeyForProbe6459: true` injected into + * the flat literal under a bare annotation). If this ever goes red, + * TypeScript or `BaseSchema` changed underneath and the docblock above + * `RemoveIndexSignature` in `ObjectGrid.tsx` needs re-measuring. + */ + it('a bare `DataTableSchema` annotation accepts a bogus key even at a fresh literal (the blind instrument)', () => { + const blind: DataTableSchema = { + type: 'data-table', + columns, + data: [], + bogusKeyRefusedNowhere: 'admitted by the index signature', + }; + expect(blind.type).toBe('data-table'); + }); + + /** + * …and the index signature is exactly what separates the two types: present + * on `DataTableSchema`, stripped from the seam type. If `@object-ui/types` + * ever removes the signature from `BaseSchema`, the first line goes red — + * a useful red: the local stripping machinery becomes redundant that day. + */ + it('the strip is real: `string` indexes `DataTableSchema` but not the seam type', () => { + type _UpstreamHasIndex = Expect; + type _SeamHasNone = Expect; + expect(true).toBe(true); + }); + + /** + * THE SAME SHAPE, against the seam type: REFUSED. One cause only — with the + * index signature stripped the key is a non-member, and the literal is fresh, + * so TS2353 names it. This is the "shown able to fail" the card demanded. + */ + it('the seam type refuses a bogus key written out in a fresh literal', () => { + const refused: ObjectGridDataTableSchema = { + type: 'data-table', + columns, + data: [], + // @ts-expect-error objectui#6459 — undeclared key, refused once the index signature is stripped. + bogusKeyRefusedHere: true, + }; + expect(refused.type).toBe('data-table'); + }); + + /** + * The group-table writer is the same instrument: `buildGroupTableSchema` + * returns a literal contextually typed by the annotation, so a longhand key + * is fresh there too, and the spread it carries comes from the already + * checked `const` — every key entering the spread was refused or admitted at + * ITS literal. Pinned in the writer's exact shape (spread + longhand keys in + * an annotated arrow return). + */ + it('the seam type refuses a bogus key in the grouped writer shape (spread + longhand)', () => { + const base: ObjectGridDataTableSchema = { type: 'data-table', columns, data: [] }; + const build = (): ObjectGridDataTableSchema => ({ + ...base, + pagination: false, + // @ts-expect-error objectui#6459 — longhand keys stay fresh in an annotated return literal. + bogusGroupKey: 1, + }); + expect(build().pagination).toBe(false); + }); + + /** + * ⭐ THE INSTRUMENT'S BOUNDARY, pinned so nobody mistakes this for + * tombstone-grade coverage. This assignment MUST COMPILE: the value is + * NON-FRESH (it has a declared type of its own), and assignability admits + * extra members — that is structural typing, not a defect. A key RETIRED by + * ruling still needs a `?: never` tombstone (see + * `ObjectGridRetiredOptionsTombstone`); the open census cannot be + * tombstoned, because a tombstone needs the key's name. If this ever goes + * red, TypeScript tightened and both docblocks need re-measuring. + */ + it('a bogus key riding a non-fresh value is admitted (assignability, the known boundary)', () => { + const staged: { type: 'data-table'; columns: TableColumn[]; data: unknown[]; smuggled?: string } = { + type: 'data-table', + columns, + data: [], + smuggled: 'assignability admits extra members', + }; + const slot: ObjectGridDataTableSchema = staged; + expect(slot.type).toBe('data-table'); + }); + + /** + * The two HELD schema-level keys — the whole census, measured on `38a123cac` + * by diffing the 46 flat-literal keys (+ the 8 group-literal keys) against + * `DataTableSchema` + `BaseSchema` declared members. Each has a live reader + * in `data-table.tsx` (`renderCellEditor` via its `(schema as any)` cast, + * `cellClassName` via destructuring into every body cell's class), so the + * seam must ACCEPT them; whether `DataTableSchema` should DECLARE them is + * the ruling this card files, not this suite's call. + */ + it('accepts the two held keys — renderCellEditor and cellClassName', () => { + const held: ObjectGridDataTableSchema = { + type: 'data-table', + columns, + data: [], + cellClassName: 'px-3 py-1', + renderCellEditor: (ctx) => (ctx.column ? null : null), + }; + expect(held.cellClassName).toBe('px-3 py-1'); + }); + + /** + * The strip is HOMOMORPHIC — declared members survive with their exact + * shapes: required stays required (`columns`), optional stays optional and + * typed (`singleClickEdit`). A strip that dropped or widened members would + * compile the seam annotation trivially and check nothing. + */ + it('declared members survive the strip with modifiers intact', () => { + type _ColumnsSurvive = Expect; + type _SingleClickTyped = Expect< + DeclaredDataTableSchema['singleClickEdit'] extends boolean | undefined ? true : false + >; + // @ts-expect-error objectui#6459 — `columns` is required; the mapped type must not have made it optional. + const missing: ObjectGridDataTableSchema = { type: 'data-table', data: [] }; + expect(missing.type).toBe('data-table'); + }); +});