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
28 changes: 28 additions & 0 deletions .changeset/6459-grid-data-table-schema-slot.md
Original file line number Diff line number Diff line change
@@ -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.
111 changes: 107 additions & 4 deletions packages/plugin-grid/src/ObjectGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> = {
[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<DataTableSchema>;

/**
* 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';

Expand Down Expand Up @@ -3288,7 +3389,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
? hostOnSearchChange
: setSearchTerm;

const dataTableSchema: any = {
const dataTableSchema: ObjectGridDataTableSchema = {
type: 'data-table',
caption: schema.label || schema.title,
columns: orderedColumns,
Expand Down Expand Up @@ -3487,7 +3588,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
}

/** 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,
Expand All @@ -3504,8 +3605,10 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// 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,
})),
Expand Down
167 changes: 167 additions & 0 deletions packages/plugin-grid/src/__tests__/dataTableSchemaSlot-6459.test.ts
Original file line number Diff line number Diff line change
@@ -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 extends true> = 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<string extends keyof DataTableSchema ? true : false>;
type _SeamHasNone = Expect<string extends keyof ObjectGridDataTableSchema ? false : true>;
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<DeclaredDataTableSchema['columns'] extends TableColumn[] ? true : false>;
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');
});
});
Loading