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
74 changes: 74 additions & 0 deletions .changeset/5853-tablecolumn-type-canonical-union.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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<string>(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);
});
});
29 changes: 22 additions & 7 deletions packages/components/src/renderers/complex/data-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 `<Input type="number">`.
const NUMERIC_EDIT_TYPES = new Set(['number', 'currency', 'percent', 'int', 'integer', 'float', 'double']);
// Column types that should edit as a numeric `<Input type="number">`.
//
// `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<TableColumnType>(['number', 'currency', 'percent']);

/**
* Human label for an object/array cell value (e.g. an expanded reference like
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2270,7 +2285,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
);
}

if (editType === 'datetime' || editType === 'datetime-local') {
if (editType === 'datetime') {
return (
<Input
ref={editInputRef}
Expand Down
16 changes: 14 additions & 2 deletions packages/plugin-dashboard/src/ObjectDataTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
columnHeader,
} from '@object-ui/core';
import type { DrillDownConfig } from '@object-ui/types';
import { normalizeTableColumnType } from '@object-ui/types';
import { Skeleton, RefreshIndicator, cn } from '@object-ui/components';
import { useSafeFieldLabel, useObjectTranslation, useLocalization, useDisplayLocale } from '@object-ui/i18n';
import { resolveFilterPlaceholders, humanizeFieldKey } from './utils';
Expand Down Expand Up @@ -462,11 +463,22 @@ export const ObjectDataTable: React.FC<ObjectDataTableProps> = ({ 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) {
Expand Down
32 changes: 30 additions & 2 deletions packages/plugin-grid/src/ObjectGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -2245,7 +2245,35 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
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];
Expand Down
Loading
Loading