diff --git a/.changeset/6882-datatable-declare-render-cell-editor-and-cell-classname.md b/.changeset/6882-datatable-declare-render-cell-editor-and-cell-classname.md new file mode 100644 index 0000000000..f7de03743e --- /dev/null +++ b/.changeset/6882-datatable-declare-render-cell-editor-and-cell-classname.md @@ -0,0 +1,56 @@ +--- +'@object-ui/types': minor +'@object-ui/components': minor +--- + +feat(types): declare `renderCellEditor` and schema-level `cellClassName` on `DataTableSchema` + +`data-table` has read both keys on its production path all along — `renderCellEditor` +through a `(schema as any)` cast, `cellClassName` by destructuring it into the class of +its three utility cells (the selection checkbox, the row number, the row actions). +Neither was declared, so authoring either one was unchecked: a misspelling produced no +error and no widget, and no editor completion offered them. +`DataTableSchema` now declares both, and the cast in `data-table.tsx` is gone rather +than replaced. + +What you can write after this change that you could not write before, exactly: +**nothing new runs.** Both keys had the same effect yesterday, because +`BaseSchema`'s `[key: string]: any` already admitted them at any type at all. What +changes is that they are now *checked* and *documented*: + +```ts +const schema: DataTableSchema = { + type: 'data-table', + columns, data, + cellClassName: 'px-2 py-1 text-sm', // utility cells only (see below) + renderCellEditor: ({ column, value, commit, cancel }) => + column.type === 'select' + ? + : null, // null → fall through to the built-in editor +}; +``` + +⚠️ **One reject direction, deliberate.** Because the keys were previously absorbed by +the index signature as `any`, authored values of the *wrong shape* also compiled and +silently did nothing. They are now compile errors: + +- `cellClassName` is declared `string`, matching `BaseSchema.className` and + `TableColumn.cellClassName`. The renderer passes it through `cn()`, which would + also swallow `['a','b']` or `{ a: true }` — those spellings now fail to compile. + One authored spelling for a class slot is the contract. +- `renderCellEditor` is declared as the function `data-table` actually calls. A + non-function value (or a function with an incompatible context/return type) now + fails to compile instead of being ignored at runtime. + +⚠️ **What the schema-level `cellClassName` actually styles.** It is NOT the +table-level twin of the per-column key: the two reach **disjoint** cells. Measured on +the render, the schema-level key is folded into the **utility** cells only — the +selection-checkbox cell, the row-number cell and the row-actions cell — while every +**data** cell folds `TableColumn.cellClassName` and nothing else. Row density is +therefore a pair of settings (`ObjectGrid` sets both), and the schema-level key alone +leaves data cells at the primitive's default `p-4`. The docblock, the zod `describe` +and `content/docs/components/complex/data-table.mdx` all say this now. + +No runtime behaviour changed anywhere, and nothing was retired. The zod mirror +(`@object-ui/types/zod`) gains both keys in the same stroke, so the validator accepts +what the published types now invite. diff --git a/content/docs/components/complex/data-table.mdx b/content/docs/components/complex/data-table.mdx index ca5031f688..ed00fdd0c0 100644 --- a/content/docs/components/complex/data-table.mdx +++ b/content/docs/components/complex/data-table.mdx @@ -43,9 +43,23 @@ interface DataTableSchema { // Advanced features resizableColumns?: boolean; // Allow column resizing (default: true) reorderableColumns?: boolean; // Allow column reordering (default: true) - + + // Inline editing + editable?: boolean; // Enable inline cell editing (default: false) + singleClickEdit?: boolean; // Enter edit mode on single click (default: false) + renderCellEditor?: (ctx: { // Host-supplied editor widget; null -> built-in input + column: any; + row: any; + value: any; + stage: (v: any) => void; + commit: (v?: any) => void; + cancel: () => void; + }) => ReactNode; + // Styling - className?: string; // Tailwind CSS classes + className?: string; // Tailwind CSS classes on the table wrapper + cellClassName?: string; // Tailwind CSS classes on the utility cells only + // (select / row number / row actions) // Base properties id?: string; @@ -54,6 +68,62 @@ interface DataTableSchema { } ``` +## Cell styling + +`className` styles the table wrapper. Body cells have **two** class slots, and they +reach **disjoint** cells — neither is a superset of the other, and no cell gets both: + +- `TableColumn.cellClassName` — the **data** cells of that one column. +- `DataTableSchema.cellClassName` — the table's **utility** cells only: the leading + selection-checkbox cell (`selectable`), the row-number cell (`showRowNumbers`), and + the trailing row-actions cell (`rowActions`). It never reaches a data cell. + +(The empty-state cell and the add-record row take neither.) + +Row density is therefore a **pair** of settings, not one. Per-cell padding is where +row height has to be expressed — height is a property of the cells, not of the row +element, so `rowClassName` cannot express it — so a compact table sets the same +density class on every column *and* on the schema, the second so the checkbox and +row-number cells stay the same height as the data beside them. That is exactly what +`object-grid` does for its `rowHeight` modes: + +```json +{ + "type": "data-table", + "selectable": true, + "showRowNumbers": true, + "cellClassName": "px-2 py-1 text-sm", + "columns": [ + { "header": "Name", "accessorKey": "name", "cellClassName": "px-2 py-1 text-sm" }, + { "header": "Amount", "accessorKey": "amount", "cellClassName": "px-2 py-1 text-sm text-right" } + ], + "data": [ + { "name": "Ada Lovelace", "amount": 120 }, + { "name": "Grace Hopper", "amount": 340 } + ] +} +``` + +Drop the per-column half and the data cells stay at the table primitive's default +`p-4` — the schema-level key alone does not compact a row. + +## Inline editing + +With `editable: true` a cell enters edit mode on double-click (or on single click +with `singleClickEdit: true`) and the table renders one of its built-in editors — +text, number, date — chosen from the column's `type`. + +`renderCellEditor` lets the host supply a widget instead. The table calls it first +for every cell it is about to edit; return a node to use it, or `null` to fall +through to the built-in editor for that column. This is how `object-grid` gives a +`select` or `lookup` cell the same dedicated control the form uses, without the +component layer having to re-implement it. + +The returned node is wrapped by the table so it inherits the exit-edit +affordances the built-in editors have: Enter commits from a single-line input, +Escape cancels, and a click outside commits. Use `stage` to record a value while +staying in edit mode, `commit` to save, and `cancel` to discard. + ## Examples ### Product Inventory diff --git a/packages/components/src/renderers/complex/data-table.tsx b/packages/components/src/renderers/complex/data-table.tsx index 57efaae010..be0dc35457 100644 --- a/packages/components/src/renderers/complex/data-table.tsx +++ b/packages/components/src/renderers/complex/data-table.tsx @@ -2294,16 +2294,17 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { // re-implement select/boolean/etc. down here in the // (fields-free) component layer. Returning null means "no // widget for this type" → fall through to the built-ins. - const injectEditor = (schema as any).renderCellEditor as - | ((ctx: { - column: any; - row: any; - value: any; - stage: (v: any) => void; - commit: (v?: any) => void; - cancel: () => void; - }) => React.ReactNode) - | undefined; + // + // This used to be `(schema as any).renderCellEditor as + // (…) => React.ReactNode` — a cast that existed for one + // reason only: `DataTableSchema` did not declare the key + // this renderer has always read, so the read had to + // re-state the contract locally and the schema had to be + // opened up to let it. objectui#6882 declared it (the + // 2026-08-30 ruling), so the read is typed at its source + // and the ctx shape below is checked against the + // declaration instead of asserted against nothing. + const injectEditor = schema.renderCellEditor; if (typeof injectEditor === 'function') { const node = injectEditor({ column: col, diff --git a/packages/types/src/__tests__/data-table-declared-keys-6882.test.ts b/packages/types/src/__tests__/data-table-declared-keys-6882.test.ts new file mode 100644 index 0000000000..d16fc08225 --- /dev/null +++ b/packages/types/src/__tests__/data-table-declared-keys-6882.test.ts @@ -0,0 +1,146 @@ +/** + * 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#6882 — `DataTableSchema` DECLARES the two schema-level keys + * `data-table.tsx` has always read: `renderCellEditor` and `cellClassName` + * (maintainer ruling 2026-08-30, option A). + * + * ## Why this pin is compile-time, and why a runtime pin would measure nothing + * + * The enforcement being added is a TYPE declaration. `data-table` behaves + * IDENTICALLY before and after it: both keys were already read on the + * production path — `renderCellEditor` through an `(schema as any)` cast, + * `cellClassName` by destructuring into every body cell's class — so no render + * changes, no runtime value changes, and a rendering test is blind to the whole + * change. What can fail is a COMPILE. Same reading as + * `plugin-grid/src/__tests__/dataTableSchemaSlot-6459.test.ts` next door. + * + * ## ⚠️ The index signature is what makes the naive pin vacuous + * + * `DataTableSchema extends BaseSchema`, and `BaseSchema` carries + * `[key: string]: any`. So `DataTableSchema['renderCellEditor']` resolves to + * `any` whether or not the key is declared, and every "is this key there?" + * spelling written over the raw type answers `true` for EVERY string — + * including `bogusKeyNobodyDeclared`. A pin written that way is green before + * the fix, green after it, and measures nothing. + * + * `Declared<>` below strips the signature so NON-MEMBERSHIP can exist, which is + * the only state in which a membership question has an answer. + * + * ## ⚠️ …and `extends` alone is vacuous a second way + * + * `Expect` is satisfied by `never` (assignable + * to everything) and by `any`. `Equal<>` below is the invariant + * (function-parameter-identity) comparison instead, so neither passes. + * + * ## How the DIRECTION is proved, rather than asserted + * + * Four `@ts-expect-error` directives below are the load-bearing half. TypeScript + * reports an UNUSED `@ts-expect-error` as an error (TS2578), so each of them is + * a claim that the instrument REFUSES something: + * + * - `Expect` must be refused → the assertion helper has teeth; + * - `Equal` and `Equal` must resolve `false` → the + * comparison is invariant, not `extends`-shaped; + * - `IsDeclaredOn<'…probe…'>` must resolve `false` → the strip really removed + * the index signature, so a non-member is answerable. + * + * Break any part of the instrument — make `Expect` accept anything, make + * `Equal` bivariant, make `Declared` a no-op — and this file goes RED on the + * now-unused directive rather than quietly passing. That is the property the + * positive assertions borrow their meaning from. + */ +import { describe, it, expect } from 'vitest'; +import type { DataTableSchema } from '../data-display.js'; + +/** + * `T` with its string/number index signatures removed — the same shape + * `plugin-grid`'s `RemoveIndexSignature` uses at the seam, restated here so + * this package's pin does not depend on a downstream package. + */ +type Declared = { + [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K]; +}; + +/** Invariant type equality. `A extends B` is NOT this: `never` and `any` pass that. */ +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; + +/** The only assertion form used here — its constraint is what refuses `false`. */ +type Expect = T; + +/** Is `K` a DECLARED member of `DataTableSchema` (index signature stripped)? */ +type IsDeclaredOn = K extends keyof Declared ? true : false; + +/* ── Direction proofs: a broken instrument makes THIS file red ─────────────── */ + +// @ts-expect-error objectui#6882 — `Expect` must refuse `false`. Widen its constraint and this directive goes unused (TS2578). +type _ExpectRefusesFalse = Expect; + +// @ts-expect-error objectui#6882 — `never` must NOT read as equal to `true`. An `extends`-shaped comparison would let it through. +type _EqualRefusesNever = Expect>; + +// @ts-expect-error objectui#6882 — `any` must NOT read as equal to `true`, for the same reason. +type _EqualRefusesAny = Expect>; + +// @ts-expect-error objectui#6882 — a key nothing declares must answer `false`. If `Declared<>` stopped stripping `[key: string]: any`, this would answer `true` and the directive would go unused. +type _UndeclaredKeyIsRefused = Expect>; + +/* ── The assertions the card is about ─────────────────────────────────────── */ + +/** RED before objectui#6882's declaration, green after. */ +type _RenderCellEditorIsDeclared = Expect>; +/** RED before objectui#6882's declaration, green after. */ +type _CellClassNameIsDeclared = Expect>; + +/** + * The context object `data-table.tsx` actually passes to the injected editor, + * transcribed from its call site. Declaring the key with any other shape is a + * different (and false) statement about the renderer, so the shape is pinned, + * not just the membership. + */ +type CellEditorContext = { + column: any; + row: any; + value: any; + stage: (v: any) => void; + commit: (v?: any) => void; + cancel: () => void; +}; + +type _RenderCellEditorShape = Expect< + Equal< + Declared['renderCellEditor'], + ((ctx: CellEditorContext) => React.ReactNode) | undefined + > +>; + +/** Matches `TableColumn.cellClassName` and `BaseSchema.className` — both `string`. */ +type _CellClassNameShape = Expect['cellClassName'], string | undefined>>; + +describe('objectui#6882 — DataTableSchema declares the two keys data-table reads', () => { + /** + * The runtime half exists only so the compile-time pins above have a file + * vitest also runs; the assertions that matter are erased before this runs. + * It does carry one honest statement: an author writing both keys produces an + * ordinary `DataTableSchema` value, no cast anywhere. + */ + it('an author can write both keys on a plain DataTableSchema value', () => { + const authored: DataTableSchema = { + type: 'data-table', + columns: [{ header: 'Name', accessorKey: 'name' }], + data: [], + cellClassName: 'px-3 py-1', + renderCellEditor: ({ value }) => (value == null ? null : null), + }; + + expect(authored.cellClassName).toBe('px-3 py-1'); + expect(typeof authored.renderCellEditor).toBe('function'); + }); +}); diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index b76c8302f7..b648c58bf0 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -919,6 +919,36 @@ export interface DataTableSchema extends BaseSchema { * @default false */ singleClickEdit?: boolean; + /** + * Host-supplied cell editor for inline editing (objectui#6882). + * + * When a cell enters edit mode the table calls this FIRST and renders what it + * returns; returning `null` means "no widget for this column" and the table + * falls through to its built-in text / number / date inputs. It exists so a + * higher layer (e.g. `ObjectGrid`) can hand a cell the SAME dedicated widget + * the form uses for that field type — select, lookup, boolean — without the + * component layer, which is deliberately `@object-ui/fields`-free, having to + * re-implement any of them. + * + * The returned node is wrapped by the table so it gains the exit-edit + * affordances the built-in editors have (Enter commits from a single-line + * input, Escape cancels, click-outside commits); `stage` records a value + * without leaving edit mode, `commit` saves, `cancel` discards. + * + * ⚠️ Declared as of objectui#6882 (maintainer ruling 2026-08-30). `data-table` + * has read this key on the production path since inline editing landed — it + * did so through a `(schema as any)` cast, which existed for no reason other + * than this declaration's absence and is gone with it. Nothing new runs; a + * misspelling is now caught at authoring time instead of failing silently. + */ + renderCellEditor?: (ctx: { + column: any; + row: any; + value: any; + stage: (v: any) => void; + commit: (v?: any) => void; + cancel: () => void; + }) => React.ReactNode; /** * Cell value change handler * Called when a cell value is edited @@ -949,6 +979,38 @@ export interface DataTableSchema extends BaseSchema { * Function that returns CSSProperties for each row (e.g., from conditionalFormatting). */ rowStyle?: (row: any, index: number) => React.CSSProperties | undefined; + /** + * Extra CSS classes folded into the table's UTILITY body cells + * (objectui#6882) — and ONLY those three, each rendered only when its + * feature is on: the leading selection-checkbox cell (`selectable`), the + * row-number cell (`showRowNumbers`), and the trailing row-actions cell + * (`rowActions`). + * + * ⚠️ It does NOT reach a data cell. A data cell folds + * {@link TableColumn.cellClassName} — the per-column key — and nothing else, + * so the two class slots style DISJOINT cells and never combine on one cell. + * (The empty-state cell and the add-record row cell take neither.) The + * population is checkable: `data-table.tsx` folds this key at exactly three + * `cn(cellClassName, …)` call sites, and the data-cell one folds + * `col.cellClassName`. + * + * Its live use is row density, and it is only half of that. Row height is a + * property of the cells, not of the `` — `rowClassName` cannot express + * it — so a host that renders compact / short / tall rows sets the per-cell + * padding on BOTH slots: `ObjectGrid` folds its density class into every + * column's `cellClassName` and passes the same class here, which is what + * keeps the checkbox and row-number cells the same height as the data beside + * them. Setting only this key leaves every data cell at the table + * primitive's default `p-4`. + * + * ⚠️ Declared as of objectui#6882 (maintainer ruling 2026-08-30). `data-table` + * has destructured this key off the schema and folded it into those three + * cells all along; only the declaration was missing. `string` matches + * {@link BaseSchema.className} and {@link TableColumn.cellClassName} — the + * renderer passes it through `cn()`, which would also swallow an array or an + * object, but one authored spelling for a class slot is the contract. + */ + cellClassName?: string; /** * Number of columns to freeze (left-pin) * When set, the first N columns remain fixed while the rest scroll horizontally. diff --git a/packages/types/src/zod/data-display.zod.ts b/packages/types/src/zod/data-display.zod.ts index 3da1839123..b396ab2aa4 100644 --- a/packages/types/src/zod/data-display.zod.ts +++ b/packages/types/src/zod/data-display.zod.ts @@ -244,6 +244,8 @@ export const DataTableSchema = BaseSchema.extend({ }).optional().describe('Per-record CEL predicates for the built-in row Delete item (objectui#2614)'), onSelectionChange: z.function().optional().describe('Selection change handler'), onColumnsReorder: z.function().optional().describe('Column reorder handler'), + cellClassName: z.string().optional().describe('Extra classes folded into the utility body cells only — the selection, row-number and row-actions cells; data cells fold the per-column `cellClassName` instead, so row density has to be set on both (objectui#6882)'), + renderCellEditor: z.function().optional().describe('Host-supplied inline cell editor; returning null falls through to the built-in text/number/date inputs (objectui#6882)'), frozenColumns: z.number().optional().describe('Number of frozen columns'), showRowNumbers: z.boolean().optional().describe('Show row numbers'), emptyAction: SchemaNodeSchema.optional().describe('Optional schema node rendered inside the empty-state, e.g. an "Add record" button. Lets the empty state become an actionable invitation rather than a dead end.'),