diff --git a/.changeset/5853-tablecolumn-type-canonical-union.md b/.changeset/5853-tablecolumn-type-canonical-union.md new file mode 100644 index 0000000000..47431dd506 --- /dev/null +++ b/.changeset/5853-tablecolumn-type-canonical-union.md @@ -0,0 +1,74 @@ +--- +'@object-ui/types': minor +'@object-ui/components': patch +'@object-ui/plugin-grid': patch +'@object-ui/plugin-dashboard': patch +--- + +`TableColumn.type` now has ONE canonical value set across all three ends that disagreed +(objectui#5853, maintainer ruling 2026-08-25, Option B: the 8-literal interface union is +canonical). The interface declared `'text' | 'number' | 'date' | 'datetime' | 'currency' | +'percent' | 'boolean' | 'action'`; the zod mirror declared `z.string()` and accepted +anything; the renderer branched on a third set and could only read the key through an +`as any` cast. + +## ⚠️ Accept-set narrowing — these spellings stop validating + +`TableColumnSchema.type` was `z.string().optional()`. **Any string parsed green.** It is now +`z.enum(TABLE_COLUMN_TYPES).optional()`, so a value outside the eight is refused at parse +time with `type` named in the error path. Spellings that validated before and are **refused +now**, grouped by why they were being written: + +- **Typos and invented names** — `'money'`, `'datetime2'`, `'string'`, `'int'`, `'integer'`, + `'float'`, `'double'`, `'datetime-local'`, and every other free-form string. `'money'` is + the card's headline case: it validated, matched no renderer branch, and the column fell + through to plain text rendering with nothing reported. That silent fall-through is the + lenient-validation face that lets AI-authored metadata errors through, and it is now a + loud parse failure. +- **Object-schema field types written into a column slot** — `'select'`, `'lookup'`, + `'user'`, `'file'`, `'formula'`, `'textarea'`, `'email'` and the other 35 members of + `@objectstack/spec`'s `FieldType` that are not among the eight. These belong on the FIELD, + not on the column: a column gets its dedicated widget from the field definition behind its + `accessorKey`, never from `type`. + +**Authored metadata in this repo needs no migration.** Measured before tightening, across +`examples/`, `content/`, `apps/`, `e2e/`, `docs/` and every package (591 JSON schema files +plus the docs and playground sources): **zero** authored `TableColumn.type` values outside +the eight, and zero occurrences of `int` / `integer` / `float` / `double` in a column +position anywhere in the repository. If you author `type` on a table column, check it +against the eight; if the value describes the FIELD rather than the column, remove it. + +## The renderer's undeclared vocabulary disappears instead of being declared + +`int` / `integer` / `float` / `double` were members of the data-table's `NUMERIC_EDIT_TYPES` +and `datetime-local` had its own editor branch, none of them declared. They arrived because +column-inference producers forwarded an object schema's field type **verbatim** into +`TableColumn.type`. Rather than publishing that dialect, producers now fold their inferred +value onto the declared vocabulary at their emit seam via the new +`normalizeTableColumnType()`: `int`/`integer`/`float`/`double` → `number`, +`datetime-local` → `datetime`, and **anything else drops the `type` annotation — never the +column**. Two producers do this, not the one the card named: `ObjectGrid` (`@object-ui/plugin-grid`) +and `ObjectDataTable` (`@object-ui/plugin-dashboard`), whose `buildFieldMeta` spread wrote +the raw field type into the same slot. + +Dropping the annotation is behaviour-preserving at the only consumer that reads the key. +`data-table`'s inline editor branches on `date`, `datetime` and the numeric set and +otherwise falls through to a text input — which is exactly the `undefined` path. The +dedicated widget a `select` or `lookup` column gets comes from the host's `renderCellEditor`, +which resolves the field through `column.accessorKey` and never reads `type`. + +## New public API + +`@object-ui/types` exports `TABLE_COLUMN_TYPES` (the canonical tuple — the single +declaration the zod mirror builds its enum from, so the two cannot drift), the +`TableColumnType` union, and `normalizeTableColumnType()` for producers. The `as any` cast +in `data-table.tsx` is deleted and the read is typed, so re-introducing an undeclared +spelling is a tsc error rather than a silent widening. + +A value-level parity pin covers all three ends +(`packages/types/src/__tests__/table-column-type-canonical.test.ts` and +`packages/components/src/renderers/complex/__tests__/table-column-type-read-set.test.tsx`). +objectui#5684's guard is key-set only and cannot see value drift — `type` was present on +both sides the whole time — which is how this instance survived while its siblings were +caught. A future inference value turning that pin red is by design; the note at the pin says +so, and names the two correct repairs. diff --git a/packages/components/src/renderers/complex/__tests__/table-column-type-read-set.test.tsx b/packages/components/src/renderers/complex/__tests__/table-column-type-read-set.test.tsx new file mode 100644 index 0000000000..9d4b8ac1cd --- /dev/null +++ b/packages/components/src/renderers/complex/__tests__/table-column-type-read-set.test.tsx @@ -0,0 +1,148 @@ +/** + * 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. + */ + +/** + * The RENDERER half of the `TableColumn.type` value-level parity pin + * (objectui#5853, maintainer ruling 2026-08-25, Option B). + * + * The declaration half — interface tuple ↔ zod mirror ↔ the producer-seam fold + * — lives in `packages/types/src/__tests__/table-column-type-canonical.test.ts`. + * This file pins the third end, the one neither of those can see: the set of + * `type` values `data-table` actually BRANCHES ON. That end is the reason the + * card existed. The renderer read a vocabulary matching neither of the other + * two (`int` / `integer` / `float` / `double` in `NUMERIC_EDIT_TYPES`, plus a + * `datetime-local` editor branch), and it could only do so through an + * `as any` cast, which is the shape "declared ≠ enforced" takes in TypeScript. + * + * ## ⭐ A new branch on an undeclared spelling turning this red is BY DESIGN + * + * ⛔ Do not repair it by re-adding the cast. Either publish the value (add it + * to `TABLE_COLUMN_TYPES`, all three ends together, `minor` + changeset), or + * fold it at the producer's emit seam so it never reaches the slot. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { screen, fireEvent } from '@testing-library/react'; +import { TABLE_COLUMN_TYPES } from '@object-ui/types'; +import { renderComponent } from '../../../__tests__/test-utils'; +// Module-scope side-effect import, not a `beforeAll` — see +// object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021). +import '../../../renderers'; + +/* ── the renderer source, comments stripped ──────────────────────────────── */ + +// `__dirname`-relative, the repo's idiom for source-reading tests: in the dom +// project `import.meta.url` is not a `file:` URL, so `new URL(…)` cannot +// resolve it. +const RAW = readFileSync(resolve(__dirname, '../data-table.tsx'), 'utf8'); +/** Code only: a spelling named in a comment must not count as a branch. */ +const CODE = RAW.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, ''); + +const DECLARED = new Set(TABLE_COLUMN_TYPES); + +/** Every literal the renderer compares `editType` against. */ +function comparisonLiterals(): string[] { + return [...CODE.matchAll(/editType\s*===\s*'([^']*)'/g)].map((m) => m[1]!); +} + +/** Every member of the `NUMERIC_EDIT_TYPES` set literal. */ +function numericSetMembers(): string[] { + const decl = CODE.match(/NUMERIC_EDIT_TYPES\s*=\s*new Set(?:<[^>]*>)?\(\[([^\]]*)\]\)/); + expect(decl).not.toBeNull(); + return [...decl![1]!.matchAll(/'([^']*)'/g)].map((m) => m[1]!); +} + +describe('the derivation instrument sees every branch the renderer takes', () => { + it('reads the column type exactly once, and not through a cast', () => { + // The cast (`(col as any).type as string | undefined`) is what let the + // undeclared vocabulary in. One typed read means one place to pin. + expect(CODE).toMatch(/const editType:\s*TableColumnType\s*\|\s*undefined\s*=\s*col\.type;/); + expect(CODE).not.toMatch(/\(col as any\)\.type/); + expect([...CODE.matchAll(/\bconst editType\b/g)]).toHaveLength(1); + }); + + it('does not branch on the column type in a form this file cannot see', () => { + // A `switch (editType)`, an `includes(editType)` against some other set, or + // a second Set of type spellings would each hide a branch from the pin. + // Whoever introduces one must upgrade the derivation with it. + expect(CODE).not.toMatch(/switch\s*\(\s*editType\s*\)/); + expect(CODE).not.toMatch(/\.includes\(\s*editType\s*\)/); + expect([...CODE.matchAll(/\.has\(\s*editType\s*\)/g)]).toHaveLength(1); + }); +}); + +describe('the renderer branches ONLY on spellings the interface publishes', () => { + it('every `editType === ...` literal is a declared column type', () => { + const undeclared = comparisonLiterals().filter((t) => !DECLARED.has(t)); + expect(undeclared).toEqual([]); + }); + + it('every NUMERIC_EDIT_TYPES member is a declared column type', () => { + const members = numericSetMembers(); + expect(members.length).toBeGreaterThan(0); + expect(members.filter((t) => !DECLARED.has(t))).toEqual([]); + }); + + it('the four numeric aliases are gone from the renderer entirely', () => { + // They were never declared; they arrived because producers forwarded an + // object schema's field type verbatim. The fold at the emit seam is what + // replaced them — see `normalizeTableColumnType`. + expect(numericSetMembers()).toEqual(['number', 'currency', 'percent']); + expect(comparisonLiterals()).not.toContain('datetime-local'); + }); +}); + +/* ── T1: what happens to a column whose type is out of union ─────────────── */ + +const OUT_OF_UNION = 'select'; + +function schemaWithColumnType(type?: string) { + return { + type: 'data-table' as const, + editable: true, + singleClickEdit: true, + columns: [ + { header: 'Name', accessorKey: 'name' }, + { header: 'Stage', accessorKey: 'stage', ...(type ? { type } : {}) }, + ], + data: [{ id: '1', name: 'Acme', stage: 'won' }], + } as any; +} + +describe('an out-of-union type degrades to the no-type path — the column survives', () => { + it('renders the column and its cell even when handed an undeclared type', () => { + // ⛔ The rule the fold must never break: an undeclared type drops the + // ANNOTATION, never the column. A producer that fails to fold still hands + // this renderer a raw spec field type, and the column must still draw. + const { container } = renderComponent(schemaWithColumnType(OUT_OF_UNION)); + expect(screen.getByText('Stage')).toBeTruthy(); + expect(container.textContent).toContain('won'); + }); + + it('opens the SAME editor as a column carrying no type at all', () => { + // This is why dropping the annotation is behaviour-preserving: the + // renderer's non-date / non-numeric path IS the `undefined` path. Both + // land on the built-in text input. + const editorFor = (type?: string): string | null => { + const { container, unmount } = renderComponent(schemaWithColumnType(type)); + const cells = container.querySelectorAll('tbody td'); + fireEvent.click(cells[1] as HTMLElement); + const input = container.querySelector('tbody input'); + const result = input ? (input.getAttribute('type') ?? '(none)') : null; + unmount(); + return result; + }; + + const withUndeclared = editorFor(OUT_OF_UNION); + const withNoType = editorFor(undefined); + expect(withUndeclared).not.toBeNull(); + expect(withUndeclared).toBe(withNoType); + }); +}); diff --git a/packages/components/src/renderers/complex/data-table.tsx b/packages/components/src/renderers/complex/data-table.tsx index f7abeceaa6..d8baf51ea6 100644 --- a/packages/components/src/renderers/complex/data-table.tsx +++ b/packages/components/src/renderers/complex/data-table.tsx @@ -12,7 +12,7 @@ import { cn } from '../../lib/utils'; import { resolveIcon } from '../action/resolve-icon'; import { useGridFieldAuthoring } from '../../context/gridFieldAuthoring'; import { ComponentRegistry, compareSortValues, evalRowPredicate, getSortValue } from '@object-ui/core'; -import type { DataTableSchema, TableSortItem } from '@object-ui/types'; +import type { DataTableSchema, TableSortItem, TableColumnType } from '@object-ui/types'; import { SchemaRenderer, useRowPredicate, usePredicateScope } from '@object-ui/react'; import { createSafeTranslation } from '@object-ui/i18n'; import { @@ -101,8 +101,17 @@ function toDateTimeInputValue(value: unknown): string { return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}T${pad2(d.getHours())}:${pad2(d.getMinutes())}`; } -// Field types that should edit as a numeric ``. -const NUMERIC_EDIT_TYPES = new Set(['number', 'currency', 'percent', 'int', 'integer', 'float', 'double']); +// Column types that should edit as a numeric ``. +// +// `int` / `integer` / `float` / `double` USED to be members (objectui#5853). +// They were never declared by `TableColumn.type` — they arrived because +// column-inference producers forwarded an object schema's field type verbatim, +// which is also why this key had to be read through an `as any` below. Those +// producers now fold their inferred value onto the declared vocabulary at their +// emit seam (`normalizeTableColumnType`), so an undeclared spelling can no +// longer reach this set. Typed as `TableColumnType` so re-adding one is a tsc +// error rather than a silent re-opening of the undeclared dialect. +const NUMERIC_EDIT_TYPES = new Set(['number', 'currency', 'percent']); /** * Human label for an object/array cell value (e.g. an expanded reference like @@ -2187,9 +2196,15 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { {isEditing ? ( (() => { // Type-aware inline editor. `col.type` is forwarded - // from ObjectGrid's column inference. Keep this a small, - // readable switch that's easy to extend. - const editType = (col as any).type as string | undefined; + // from a producer's column inference, folded onto the + // DECLARED vocabulary at that producer's emit seam + // (objectui#5853). This used to be + // `(col as any).type as string | undefined` — a cast that + // existed only because the values arriving were not the + // values `TableColumn` declares. They are now, so the read + // is typed and the switch below can only branch on + // spellings the interface actually publishes. + const editType: TableColumnType | undefined = col.type; // Host-injected editor: a higher layer (ObjectGrid) renders // the dedicated @object-ui/fields widget for this field's @@ -2270,7 +2285,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { ); } - if (editType === 'datetime' || editType === 'datetime-local') { + if (editType === 'datetime') { return ( = ({ schema, dataSo const inferredAlign = (col as any).align ?? (isNumericFieldMeta(fieldMeta) ? 'right' : undefined); - if (typeof col.cell === 'function') return { ...col, ...fieldMeta, align: inferredAlign }; + // ⭐ THE SECOND EMIT SEAM (objectui#5853). `buildFieldMeta` returns + // `type: overrides.type ?? meta?.type` — the OBJECT SCHEMA's field type — + // and spreading `...fieldMeta` writes it straight into the column's + // `type`, the same verbatim forwarding `ObjectGrid` does at its own seam. + // The card's census named ObjectGrid as the only inference producer; this + // is the second one, and it gets the same fold so `TableColumn.type` only + // ever holds a value that type declares. An out-of-union type drops the + // `type` KEY, never the column — display here is driven by `cell` below, + // which reads `fieldMeta`, not `col.type`, so it is unaffected. + const columnType = normalizeTableColumnType(fieldMeta.type); + + if (typeof col.cell === 'function') return { ...col, ...fieldMeta, type: columnType, align: inferredAlign }; // Tenant-default currency backstops a currency column with no explicit code. const cell = (value: any): React.ReactNode => renderFieldValue(value, fieldMeta, tenantCurrency, displayLocale); - return { ...col, ...fieldMeta, align: inferredAlign, cell }; + return { ...col, ...fieldMeta, type: columnType, align: inferredAlign, cell }; }; if (schema.columns && schema.columns.length > 0) { diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 13026dcbd4..0a1e7f9040 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -23,7 +23,7 @@ import React, { useEffect, useState, useCallback, useMemo } from 'react'; import type { ObjectGridSchema, DataSource, ListColumn, ViewData, TableSortItem, DataTableSchema, ListViewExportFormat } from '@object-ui/types'; -import { isSystemManagedField } from '@object-ui/types'; +import { isSystemManagedField, normalizeTableColumnType } from '@object-ui/types'; import type { I18nLabel } from '@objectstack/spec/ui'; import { SchemaRenderer, useDataScope, useNavigationOverlay, useAction, useSafeFieldLabel, usePredicateScope, useRelatedRecordActions } from '@object-ui/react'; import { createSafeTranslation } from '@object-ui/i18n'; @@ -2245,7 +2245,35 @@ export const ObjectGrid: React.FC = ({ next.editable = false; } return next; - }); + }) + // ⭐ THE EMIT SEAM (objectui#5853, maintainer ruling 2026-08-25, Option B). + // + // Every column this component hands to `data-table` passes through here, so + // it is the one place that can guarantee `TableColumn.type` only ever holds + // a value that type DECLARES. Five paths above write `type`: the four + // column literals inside `generateColumns()` and the `fieldDef.type` + // enrichment in the map above — all of them forward an OBJECT SCHEMA's + // field type verbatim, whose vocabulary is `@objectstack/spec`'s `FieldType` + // (49 values, only 7 of them members of the declared union). That verbatim + // forwarding is why the renderer had to read this key through an `as any`. + // + // ⛔ Deliberately a SEPARATE pass, not folded into the map above: that map + // early-returns for `_actions` and for any column whose `accessorKey` has no + // `fieldDef`, and a heuristic `inferColumnType()` type (`select`, `user`) + // rides out on exactly those columns. Normalizing there would miss them. + // + // An out-of-union type drops the `type` KEY — never the column. See + // `normalizeTableColumnType` for why absence beats folding onto `'text'`. + .map((col: any) => { + if (!col || col.type == null) return col; + const normalized = normalizeTableColumnType(col.type); + if (normalized === col.type) return col; + if (normalized === undefined) { + const { type: _undeclared, ...rest } = col; + return rest; + } + return { ...col, type: normalized }; + }); // Apply persisted column order and widths let persistedColumns = [...columns]; diff --git a/packages/plugin-grid/src/__tests__/columnTypeEmitSeam.test.tsx b/packages/plugin-grid/src/__tests__/columnTypeEmitSeam.test.tsx new file mode 100644 index 0000000000..3cfc0d68b8 --- /dev/null +++ b/packages/plugin-grid/src/__tests__/columnTypeEmitSeam.test.tsx @@ -0,0 +1,111 @@ +/** + * 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. + */ + +/** + * The EMIT SEAM half of the `TableColumn.type` repair (objectui#5853, + * maintainer ruling 2026-08-25, Option B: the 8-literal interface union is + * canonical). + * + * `ObjectGrid` produces `TableColumn[]` for `data-table`, and five paths inside + * it write `type` — the four column literals in `generateColumns()` and the + * `fieldDef.type` enrichment after it. Every one forwarded an OBJECT SCHEMA's + * field type verbatim, whose vocabulary is `@objectstack/spec`'s `FieldType`: + * 49 values, only 7 of them members of the declared union. That is what made + * the declaration a lie and forced an `as any` in the renderer. + * + * The ruling's repair is a fold at the seam, so the undeclared dialect + * DISAPPEARS rather than getting declared. This file pins the fold + * BEHAVIOURALLY — through the editor a column actually opens — because the + * source-level pins next door cannot see whether the fold is reached. + * + * ⭐ Note what the first case would do WITHOUT the fold: `int` is no longer a + * member of the data-table's `NUMERIC_EDIT_TYPES` (it never was declared), so + * an unfolded `int` column would open a plain TEXT box. The numeric editor + * surviving is the fold working. + */ +import { describe, it, expect } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; + +import { ObjectGrid } from '../ObjectGrid'; +import { registerAllFields } from '@object-ui/fields'; +import { ActionProvider } from '@object-ui/react'; + +registerAllFields(); + +const ROWS = [ + { id: '1', name: 'Ada', amount: 100 }, + { id: '2', name: 'Grace', amount: 200 }, +]; + +/** + * Render an inline-data grid with one typed column. No `objectName` and no + * object schema — deliberately, because that is the path whose columns SKIP the + * `fieldDef` enrichment map (it early-returns when the accessor has no field + * def). The fold is a separate pass precisely so those columns are covered too. + */ +function renderTyped(type: string) { + return render( + + + , + ); +} + +/** The `type` attribute of the editor the Amount cell opens, or null. */ +function editorTypeFor(type: string): string | null { + const { container, unmount } = render(
); + unmount(); + const view = renderTyped(type); + const cell = screen.getAllByText('100')[0]!.closest('td') as HTMLElement; + fireEvent.click(cell); + const input = view.container.querySelector('tbody input') as HTMLInputElement | null; + const result = input ? input.getAttribute('type') : null; + view.unmount(); + void container; + return result; +} + +describe('ObjectGrid folds an inferred column type onto the declared vocabulary', () => { + it('a declared numeric type opens the numeric editor (the control)', () => { + // Control probe: `number` IS declared, so this passes with or without the + // fold. It is here so a null reading below reads as a real difference + // rather than as "inline editing never engaged in this harness". + expect(editorTypeFor('number')).toBe('number'); + }); + + it('the undeclared alias `int` still opens the NUMERIC editor — via the fold', () => { + // `int` reaches `TableColumn.type` from a producer, is folded to `number` + // at the seam, and the renderer branches on the declared spelling. Remove + // the fold and this returns 'text': the renderer no longer knows `int`. + expect(editorTypeFor('int')).toBe('number'); + }); + + it.each(['integer', 'float', 'double'])('folds the alias %s the same way', (alias) => { + expect(editorTypeFor(alias)).toBe('number'); + }); + + it('an out-of-union field type leaves the column standing', () => { + // ⛔ T1's rule: the fold drops the undeclared ANNOTATION, never the column. + // `select` is one of the 42 spec field types outside the union, and it is a + // spelling authored in this repo's own grid fixtures today. + renderTyped('select'); + expect(screen.getByText('Amount')).toBeInTheDocument(); + expect(screen.getAllByText('100')[0]).toBeInTheDocument(); + expect(screen.getAllByText('200')[0]).toBeInTheDocument(); + }); +}); diff --git a/packages/types/src/__tests__/table-column-type-canonical.test.ts b/packages/types/src/__tests__/table-column-type-canonical.test.ts new file mode 100644 index 0000000000..34f4b88ff3 --- /dev/null +++ b/packages/types/src/__tests__/table-column-type-canonical.test.ts @@ -0,0 +1,149 @@ +/** + * 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. + */ + +/** + * VALUE-LEVEL parity pin for `TableColumn.type` (objectui#5853, maintainer + * ruling 2026-08-25, Option B: the 8-literal interface union is canonical). + * + * ## Why a value-level pin exists at all + * + * objectui#5684's anti-drift guard is KEY-SET only — it checks that a key is + * present on both the interface and its zod mirror. It cannot see a VALUE + * schema diverging, which is exactly how this instance survived while its + * siblings were caught: `type` was present on both sides the whole time, the + * interface declaring 8 literals and the mirror declaring `z.string()`. Without + * a pin at the value level the three ends re-diverge silently. + * + * ## ⭐ A new inference value turning this file red is BY DESIGN + * + * If a producer starts inferring a new column type, or the renderer starts + * branching on a spelling the interface does not publish, the read-set pin + * below goes red. That is the alarm working, not a defect in the alarm. + * ⛔ Do NOT "fix" it by loosening the mirror back toward `z.string()` or by + * appending the new spelling to the alias table. The two correct repairs are: + * add the value to {@link TABLE_COLUMN_TYPES} (all three ends move together, + * `minor` + a changeset), or fold it at the producer's emit seam so the + * undeclared spelling never reaches the slot — the repair this card shipped. + * + * The renderer-source half of the pin lives next to the renderer, in + * `packages/components/src/renderers/complex/__tests__/`. + */ + +import { describe, it, expect } from 'vitest'; +import { FieldType as SpecFieldTypeEnum } from '@objectstack/spec/data'; +import { TABLE_COLUMN_TYPES, normalizeTableColumnType } from '../data-display'; +import type { TableColumnType } from '../data-display'; +import { TableColumnSchema } from '../zod/data-display.zod'; + +/** The `type` member's declared options, read off the live zod schema. */ +function mirrorOptions(): string[] { + const shape = (TableColumnSchema as unknown as { shape: Record }).shape; + const member = shape.type; + const inner = typeof member?.unwrap === 'function' ? member.unwrap() : member; + return [...(inner.options as string[])]; +} + +describe('the canonical vocabulary is declared exactly once', () => { + it('publishes the 8 literals the ruling made canonical', () => { + expect([...TABLE_COLUMN_TYPES]).toEqual([ + 'text', 'number', 'date', 'datetime', 'currency', 'percent', 'boolean', 'action', + ]); + }); + + it('the zod mirror enumerates the SAME values, in the same order', () => { + // The mirror builds its `z.enum` from `TABLE_COLUMN_TYPES`, so this is a + // guard against someone restating the members by hand — the shape the + // divergence took last time. + expect(mirrorOptions()).toEqual([...TABLE_COLUMN_TYPES]); + }); + + it('the mirror is no longer a bare string — `money` is refused loudly', () => { + // The card's headline defect: this parsed GREEN, matched no renderer + // branch, and the column silently fell through to plain text rendering. + const bad = TableColumnSchema.safeParse({ header: 'A', accessorKey: 'a', type: 'money' }); + expect(bad.success).toBe(false); + // Loud means the failure NAMES the key, so an author can act on it. + expect(bad.error!.issues[0]!.path).toEqual(['type']); + }); + + it.each(['int', 'integer', 'float', 'double', 'datetime-local', 'select', 'banana'])( + 'refuses the out-of-union spelling %s at parse time', + (spelling) => { + expect( + TableColumnSchema.safeParse({ header: 'A', accessorKey: 'a', type: spelling }).success, + ).toBe(false); + }, + ); + + it.each([...TABLE_COLUMN_TYPES])('accepts the declared spelling %s', (spelling) => { + expect( + TableColumnSchema.safeParse({ header: 'A', accessorKey: 'a', type: spelling }).success, + ).toBe(true); + }); + + it('the key stays optional — a column with no type is still valid', () => { + expect(TableColumnSchema.safeParse({ header: 'A', accessorKey: 'a' }).success).toBe(true); + }); +}); + +describe('normalizeTableColumnType is TOTAL over everything a producer can emit', () => { + const declared = new Set(TABLE_COLUMN_TYPES); + + it('is identity on every declared spelling', () => { + for (const t of TABLE_COLUMN_TYPES) { + expect(normalizeTableColumnType(t)).toBe(t); + } + }); + + it.each([ + ['int', 'number'], + ['integer', 'number'], + ['float', 'number'], + ['double', 'number'], + ['datetime-local', 'datetime'], + ] as const)('folds the undeclared dialect %s onto %s', (alias, canonical) => { + expect(normalizeTableColumnType(alias)).toBe(canonical); + }); + + it('never yields an undeclared value for ANY spec FieldType', () => { + // The load-bearing property. Column inference reads an object schema's + // field type, whose vocabulary is the spec's — 49 values, only 7 of them + // members of this union. Forwarding that verbatim is what made the + // declaration a lie; the fold is what makes it true again. + const specTypes = SpecFieldTypeEnum.options as readonly string[]; + expect(specTypes.length).toBeGreaterThan(40); + + const leaked: string[] = []; + for (const t of specTypes) { + const out = normalizeTableColumnType(t); + if (out !== undefined && !declared.has(out)) leaked.push(`${t} -> ${out}`); + } + expect(leaked).toEqual([]); + }); + + it('drops the ANNOTATION for an out-of-union type rather than guessing text', () => { + // T1's general case. `undefined` says only what is true — this column's + // type is not one of the 8. `'text'` would assert something false about a + // `lookup` column, and that lie would leak into any future reader of the + // key. The COLUMN itself is never dropped; only this annotation is. + for (const t of ['select', 'lookup', 'user', 'file', 'formula', 'json', 'vector']) { + expect(normalizeTableColumnType(t)).toBeUndefined(); + } + }); + + it('is undefined-safe for the non-string values a loose producer can hand it', () => { + for (const v of [undefined, null, 42, {}, [], true]) { + expect(normalizeTableColumnType(v)).toBeUndefined(); + } + }); + + it('returns a value assignable to TableColumnType, so the fold types the slot', () => { + const out: TableColumnType | undefined = normalizeTableColumnType('int'); + expect(out).toBe('number'); + }); +}); diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index 44a3416da7..67066d0856 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -212,6 +212,81 @@ export interface TableSortItem { order: 'asc' | 'desc'; } +/** + * Every `type` spelling a `TableColumn` may carry — the canonical value set for + * this key (objectui#5853, maintainer ruling 2026-08-25, Option B: the 8-literal + * interface union is canonical). + * + * This tuple is the SINGLE declaration of that vocabulary. `TableColumnSchema` + * in `zod/data-display.zod.ts` builds its `z.enum` from this array rather than + * restating the members, so the interface and the validator cannot drift apart + * the way they had (the interface declared these 8; the mirror was a bare + * `z.string()` that blessed `type: 'money'` and any other typo). + */ +export const TABLE_COLUMN_TYPES = [ + 'text', 'number', 'date', 'datetime', 'currency', 'percent', 'boolean', 'action', +] as const; + +/** Data type a table column is formatted/edited as. */ +export type TableColumnType = (typeof TABLE_COLUMN_TYPES)[number]; + +/** + * Undeclared `type` spellings the data-table renderer used to read, folded onto + * the canonical spelling they mean (objectui#5853). + * + * These are NOT part of the published vocabulary and deliberately do not appear + * in {@link TABLE_COLUMN_TYPES}: the ruling rejected alias proliferation, so the + * renderer's extra dialect DISAPPEARS at the producer seam instead of getting + * declared. `int` / `integer` / `float` / `double` were members of the + * data-table's `NUMERIC_EDIT_TYPES`; `datetime-local` had its own editor branch. + * Same treatment, and the same wording, as the param-type dialect documented at + * {@link ObjectUiLocalParamFieldType} in `ui-action.ts` (`datetime-local` → + * `datetime` there too). + * + * Authoring one of these is refused by `TableColumnSchema` — the fold exists for + * VALUES IN FLIGHT from a column-inference producer, not for authored metadata. + */ +const TABLE_COLUMN_TYPE_ALIASES: Readonly> = { + int: 'number', + integer: 'number', + float: 'number', + double: 'number', + 'datetime-local': 'datetime', +}; + +/** + * Fold an inferred column type onto the canonical {@link TableColumnType} + * vocabulary, for use at a producer's emit seam (objectui#5853). + * + * Column inference reads an OBJECT SCHEMA's field type, whose vocabulary is + * `@objectstack/spec`'s `FieldType` — 49 values, only 7 of which are members of + * this union. Forwarding that verbatim into `TableColumn.type` is what made the + * declaration a lie and forced an `as any` cast in the renderer. Producers call + * this at the point they hand columns to `data-table`, so the slot only ever + * holds a value it declares. + * + * Three outcomes, and the third is the load-bearing one: + * + * - a canonical spelling passes through unchanged; + * - a known alias folds onto its canonical spelling (`int` → `number`); + * - ⭐ ANYTHING ELSE yields `undefined` — the `type` ANNOTATION is dropped, and + * the COLUMN IS NEVER DROPPED. This is the general case, and it is where the + * 42 out-of-union spec field types (`select`, `lookup`, `user`, `file`, + * `formula`, …) land. Dropping the annotation is behaviour-preserving at the + * only consumer that reads this key: `data-table`'s inline editor branches on + * `date` / `datetime` / the numeric set and otherwise falls through to a text + * input — which is exactly the `undefined` path. The dedicated widget those + * fields DO get is chosen by the host's `renderCellEditor`, which resolves the + * field through `column.accessorKey` and never reads `type`. Mapping them to + * `'text'` instead would assert something false about a `lookup` column; + * absence says only what is true — this column's type is not one of the 8. + */ +export function normalizeTableColumnType(value: unknown): TableColumnType | undefined { + if (typeof value !== 'string') return undefined; + if ((TABLE_COLUMN_TYPES as readonly string[]).includes(value)) return value as TableColumnType; + return TABLE_COLUMN_TYPE_ALIASES[value]; +} + /** * Table column definition */ @@ -252,7 +327,7 @@ export interface TableColumn { /** * Data type for formatting */ - type?: 'text' | 'number' | 'date' | 'datetime' | 'currency' | 'percent' | 'boolean' | 'action'; + type?: TableColumnType; /** * Whether column is sortable * @default true diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index fb7687d4a9..1173590c61 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -206,8 +206,13 @@ export type { HtmlSchema, StatisticSchema, DataDisplaySchema, + TableColumnType, } from './data-display.js'; +// The canonical `TableColumn.type` vocabulary and the producer-seam fold that +// keeps undeclared inference values out of that slot (objectui#5853). +export { TABLE_COLUMN_TYPES, normalizeTableColumnType } from './data-display.js'; + // ============================================================================ // Feedback Components - Status & Progress Indication // ============================================================================ diff --git a/packages/types/src/zod/data-display.zod.ts b/packages/types/src/zod/data-display.zod.ts index d8c289287d..ef30c481cc 100644 --- a/packages/types/src/zod/data-display.zod.ts +++ b/packages/types/src/zod/data-display.zod.ts @@ -19,6 +19,7 @@ import { z } from 'zod'; import { ChartTypeSchema as SpecChartTypeSchema } from '@objectstack/spec/ui'; import { BaseSchema, SchemaNodeSchema } from './base.zod.js'; +import { TABLE_COLUMN_TYPES } from '../data-display.js'; /** * Alert Schema - Alert/notification component @@ -106,7 +107,14 @@ export const TableColumnSchema = z.object({ minWidth: z.union([z.string(), z.number()]).optional().describe('Minimum width'), align: z.enum(['left', 'center', 'right']).optional().describe('Column alignment'), fixed: z.enum(['left', 'right']).optional().describe('Fixed column position'), - type: z.string().optional().describe('Column type'), + // The canonical value set, built from the ONE declaration in + // `../data-display.ts` rather than restated here (objectui#5853, maintainer + // ruling 2026-08-25, Option B). This key was `z.string()`: every typo passed + // — `type: 'money'` validated green, matched no renderer branch, and the + // column silently fell through to plain text rendering. That is the lenient + // -validation face that lets AI-authored metadata errors through, and it is + // now a loud parse failure naming the key. + type: z.enum(TABLE_COLUMN_TYPES).optional().describe('Column type'), sortable: z.boolean().optional().describe('Whether column is sortable'), filterable: z.boolean().optional().describe('Whether column is filterable'), resizable: z.boolean().optional().describe('Whether column is resizable'),