feat(DataTable): add type-aware cell renderers on Column#247
Conversation
Adds `type` (text/number/money/date/badge/link) and `typeOptions` to `Column<TRow>`. When set, the cell is rendered automatically from `accessor(row)` (or `row[id]`), eliminating boilerplate for the common cases. `render` becomes optional when `type` is provided and continues to win when both are present, preserving the per-column escape hatch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
commit: |
|
/review |
Restructure Column<TRow> as ColumnBase<TRow> & ColumnTypeBranch<TRow>, keyed off `type`. typeOptions now narrows to a per-type interface (NumberCellOptions, MoneyCellOptions, etc.), so wrong-shape options are a compile error instead of being silently ignored at runtime. Two additional safety gains: - `type: "link"` requires typeOptions.href (was silently degraded to plain text when missing). - Untyped columns still require `render` (matches pre-feature behavior); passing neither type nor render is now a compile error rather than rendering an em-dash placeholder. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add an "Adding a typed column" section that walks through how to define columns for each built-in type — text, number, money, date, badge, link — with the parameters they accept. Includes the override-with-render escape hatch and a recipe for layering type on top of inferColumns. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The discriminated-union refactor put `render` inside each type branch,
which broke contextual typing on the common spread-then-override pattern
(`column({ ...inferred, render: (row) => ... })`) — TS couldn't pick a
branch before typing the callback, leaving `row` as implicit any.
Two fixes:
1. Move `render` onto `ColumnBase` (always optional). Callback contextual
typing now resolves uniformly across all type branches. The "render
required when type is omitted" compile check is dropped — callers with
neither type nor render fall through to the em-dash placeholder, which
matches pre-feature behavior.
2. Export `ColumnBase` and `ColumnTypeBranch` publicly so api-extractor
includes them in the rolled-up `.d.ts`. They were tagged `@internal`,
which stripped them from the dist and left `Column<TRow>` referencing
undeclared types.
All other type-safety guarantees from the discriminated union remain
(wrong-shape `typeOptions` errors, `link` requires `href`, `text`
rejects `typeOptions`).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
@copilot does |
No — currently |
|
@erickteowarang Can you cover the corner case like what happens when the data is array/object? I guess there should be some limitation or adoption for data structure. Supporting array will be needed anyway (I remember that I have been reported that |
Do you mean if someone sends in an array of like, badges to the columns? It won't crash, but it will result in text not appearing properly (e.g if you send in an array of string to the I can add some defensive coding for that use case (e.g just render array/object data as a placeholder string). Future array support should probably be done in a separate PR though. |
…type The discriminated-union refactor narrowed `typeOptions` per `type`, but `accessor` stayed on `ColumnBase` as `(row: TRow) => unknown`. That meant an accessor returning an array or plain object would silently flow into the typed renderers and produce visible garbage — `text` rendered `[object Object]`, `badge` and `link` rendered a badge / link labeled `[object Object]`, etc. Move `accessor` onto each branch of `ColumnTypeBranch` and narrow its return type to values the matching renderer can display: - `text` → string | number | boolean | bigint | null | undefined - `number`/`money` → number | null | undefined - `date` → Date | string | number | null | undefined - `badge`/`link` → string | number | boolean | null | undefined - untyped → unchanged (`unknown` — pairs with `render`) `null` and `undefined` stay in every typed branch on purpose: each built-in renderer maps them to the `—` placeholder so columns can be defined over sparse / optional fields without coercion. A row with `placedAt: null` next to a row with `placedAt: "2026-04-09…"` renders the formatted date for one and the placeholder for the other — same runtime behavior as before, now reflected in the types. Verified against the workspace: examples and tests typecheck without changes. The contextual-typing problem that pushed `render` back onto `ColumnBase` in 589a252 doesn't bite here because the dominant pattern is `{ ...infer(field), render: ... }` (override `render`, not `type`), which keeps the spread inside the untyped branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
@IzumiSy Updated now to make it ultra specific for each column type. I think this makes it safer (will fail on compile time) and more extendable (so if Badge accepts an array in the future, we can just adjust the type) |
Brings in #247 (type-aware cell renderers), #248 (column align), #251 (oxlint upgrade), #253 (accessor narrowing docs), and #254 (inferColumns no longer carries an accessor). Conflicts: - types.ts: dropped the duplicate `accessor` declaration from `ColumnBase` (it now lives per-branch in `ColumnTypeBranch` per #247) and kept both new fields — `align` (from #248) and `truncate` (from this branch). Updated `truncate`'s JSDoc to describe the Tooltip wiring rather than the `title` attribute. - field-helpers.ts: kept main's spread-based `column()` so the discriminated union survives. - data-table.tsx: combined `align` and `truncate` into the same cell classes; rebuilt `content` via `col.render ?? renderTypedCell(...)` (from #247). - data-table.test.tsx / docs: kept both feature describe blocks and combined doc tables. Per @IzumiSy's review (#249), the truncate tooltip now uses the app-shell `<Tooltip>` component (with a `Tooltip.Provider` mounted at `DataTable.Root`) instead of the browser `title` attribute. The cell is wrapped in `Tooltip.Trigger` only when `accessor` returns a stringifiable primitive — objects / arrays / no accessor still apply the truncate CSS but skip the tooltip wiring. Tests: 1010 passing (was 992 + 8 new truncate tests). Lint, fmt, workspace typecheck all clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
type(text/number/money/date/badge/link) and atypeOptionsfield toColumn<TRow>. Whentypeis set, the cell is rendered automatically fromaccessor(row)(orrow[id]whenaccessoris omitted), eliminating the inline boilerplate that every list table re-implements for currency, date, and badge formatting.renderis always optional and continues to win when both are present, so the existing escape hatch is preserved.null/undefined/"") render a muted—placeholder uniformly across types;linkcells render plain text whentypeOptions.hrefreturns nullish.Type safety
Column<TRow>is a discriminated union keyed offtype(ColumnBase<TRow> & ColumnTypeBranch<TRow>).typeOptionsnarrows per branch to a dedicated interface (NumberCellOptions,MoneyCellOptions<TRow>,DateCellOptions,BadgeCellOptions,LinkCellOptions<TRow>), and the following become compile errors rather than runtime no-ops:Three
@ts-expect-errorassertions are checked in via the test suite to lock these guarantees in.renderlives onColumnBase(not per-branch) so callback contextual typing still works on the commoncolumn({ ...inferred, render: (row) => ... })spread pattern —rowinfers without annotation.ColumnBaseandColumnTypeBranchare both exported publicly so api-extractor rolls them into the dist.d.ts.Accessor return type is also narrowed per branch
accessorlives on each branch (not onColumnBase) so each typed renderer can constrain what the column produces. Returning an array or a plain object from a typed accessor is a compile error instead of silently rendering[object Object]or a stringified list:Per-branch return types:
typeaccessorreturn typeunknown— pairs withrendertextstring | number | boolean | bigint | null | undefinednumbernumber | null | undefinedmoneynumber | null | undefineddateDate | string | number | null | undefinedbadgestring | number | boolean | null | undefinedlinkstring | number | boolean | null | undefinedWhy this is part of a three-PR series
This is 1 / 3 in a series porting patterns from a downstream consumer's
ReconciliationListinto the platformDataTable. The remaining two PRs addalignandtruncateonColumnas independent, mechanical changes — landing as separate PRs to keep each diff small and reviewable.Notes for reviewers
packages/core/src/components/data-table/cell-renderers.tsx. Pure functions per type;renderTypedCelldispatches once per cell inDataTable.Bodyand narrowscol.typeOptionsper-branch viaswitch (col.type)— no casts.moneyformatting accepts a string or(row) => stringforcurrency, mirroring thecurrencyKeypattern in the source component but in a typesafe way. Falls back to"USD"if the resolved currency code is invalid (Intl throws otherwise).linkcells use react-router'sLink(already re-exported from app-shell) so SPA navigation is preserved.docs/components/data-table.mdget:Columnreference (shared fields + per-typefields tables) reflecting the discriminated-union shaperenderescape hatch, and how to layertypeon top ofinferColumnsPublic type additions
ColumnCellType"text" | "number" | "money" | "date" | "badge" | "link"ColumnBase<TRow>ColumnTypeBranch<TRow>typebranches with narrowedtypeOptionsandaccessorNumberCellOptionstype: "number"MoneyCellOptions<TRow>type: "money"(currencyaccepts a function)DateCellOptionstype: "date"BadgeCellOptionstype: "badge"LinkCellOptions<TRow>type: "link"(hrefrequired)BadgeVariant<Badge>Test plan
pnpm --filter @tailor-platform/app-shell type-check— cleanpnpm --filter @tailor-platform/app-shell test— 992 passed (was 974; +18 new cases: per-type rendering, accessor fallback, custom-render override, and 4 type-level@ts-expect-errorassertions including array/object accessor rejection per typed branch)pnpm --filter @tailor-platform/app-shell lint— cleanpnpm --filter @tailor-platform/app-shell build— dist.d.tsincludesColumnBaseandColumnTypeBranchpnpm -r type-check— workspace clean (nextjs + vite demos typecheck unchanged against narrowedaccessor)pnpm fmt:check— clean across 342 filescolumn({ label, type: "money", accessor })into the data-table demo and confirm the column renders without arenderprop🤖 Generated with Claude Code