wcz-layout
Version:
276 lines (244 loc) • 10.4 kB
Markdown
---
name: table
description: "Use when building or changing a data table, grid, or list page. Covers useLayoutTable, LayoutTable, createLayoutColumnHelper, column definitions and headers, cell formatters, filter variants, sorting, row selection and bulk actions, per-row actions, pagination, footer aggregation."
metadata:
type: convention
library: wcz-layout
---
# Table patterns
> Mechanics (`createTableHook`, column helpers, row models, faceting, the state
> atoms) belong to TanStack Table v9 itself. List the skills shipped by
> `@tanstack/table-core` and `@tanstack/react-table` and load whichever cover the task at
> hand. This skill covers only the wcz-layout conventions on top of it.
## Rules
- For selection and actions display columns, always set `size: 50`,
`enableResizing: false`, and `enableCellSelection: false`.
- Create the column helper once at module scope, outside the component:
`const columnHelper = createLayoutColumnHelper<Todo>()`. Wrap the array in
`columnHelper.columns([...])` so each column keeps its own value type.
- Build the instance with `useLayoutTable({ data, columns })` and pass it to
`<LayoutTable table={table} />`. Rows are keyed by `row.id`, so every row object
needs an `id`.
- Render cells with the bound components off the render prop
(`cell.ValueCell`, `NumberCell`, `DateCell`, `DateTimeCell`, `BooleanCell`)
instead of raw JSX. They carry the alignment, the number and date locale, and
the noWrap behaviour. Write raw JSX only when the cell is a link or
another interactive element.
- Set `meta.variant` for non-text filters. Omission means `"text"`.
- Give `meta.label` to any column whose `header` is not a plain string. A string
header supplies the label automatically; a function or JSX header leaves the
header menu and column selector without a label; the filter picker falls back to
`column.id`. `meta` has four keys: `label`, `align`, `variant`, `options`.
- Do not enumerate `meta.options` for a free-text column. `select` and
`multi-select` read their options from the faceted unique values, so the choices
track the data. Pass explicit string options when labels differ from stored values or
the UI needs a fixed list. Derive database-enum options from `enumValues`, never a
second hand-written list.
- Translate every `header`. The table's own strings come from the app's
`src/lib/locales/*.json` under `Layout.Table`. See the `general` skill for the
setup step: the app owns the whole `Layout` namespace, not just `Layout.Table`.
- Wrap the table in `Fullscreen` when it is the only content on the page.
- Rows and columns virtualize by default. Pass `pagination` for page-based navigation.
Virtualization reduces rendered DOM; it does not reduce fetched data or replace
server-side pagination. Column virtualization
switches off for grouped header rows and while `loading`, which is a real perf
cliff on wide tables that use `columnHelper.group`.
- Props go on `LayoutTable`, not on a slot: `title`, `actions`, `showToolbar`,
`loading`, `pagination`, `pageSizeOptions`, `onRowClick`, `onRowDoubleClick`,
`sx`, and the `disableSearch` / `disableColumnFilter` / `disableColumnSelector` /
`disableColumnMenu` escape hatches.
- Omit the second `useLayoutTable` selector when passing its result to `LayoutTable`; the
renderer requires the full selected state.
- For bulk actions read `table.getSelectedRowIds()` and clear with
`table.resetRowSelection()`. For per-row actions add a `display` column rendering
`cell.ActionsCell` with a `RowAction[]`.
- A footer total needs both halves: `aggregationFn` on the column and
`footer: ({ header }) => <header.AggregationFooter />`.
## Example
```tsx
import { Fullscreen, LayoutTable, RouterIconButton, RouterLink } from "wcz-layout/components";
import {
createLayoutColumnHelper,
useDialogs,
useLayoutTable,
useTranslation,
} from "wcz-layout/hooks";
const columnHelper = createLayoutColumnHelper<Todo>();
function RouteComponent() {
const { t } = useTranslation();
const { confirm, alert } = useDialogs();
const navigate = useNavigate();
const todos = useTodoCollection();
const { data, isLoading } = useLiveQuery(todosQueryOptions);
const columns = columnHelper.columns([
columnHelper.display({
id: "select",
header: ({ header }) => <header.SelectAllCell scope="all" />,
cell: ({ cell }) => <cell.SelectCell />,
size: 50,
enableResizing: false,
}),
columnHelper.accessor("name", {
header: t("Todo.Name"),
cell: ({ cell }) => (
<RouterLink
to="/todos/$id"
params={{ id: cell.row.original.id }}
underline="hover"
color="inherit"
>
{cell.getValue()}
</RouterLink>
),
size: 260,
}),
columnHelper.accessor("assignee", {
header: t("Todo.Assignee"),
cell: ({ cell }) => <cell.ValueCell />,
// No options: the filter builds them from the faceted unique values.
meta: { variant: "multi-select" },
size: 180,
}),
columnHelper.accessor("priority", {
header: t("Todo.Priority"),
cell: ({ cell }) => <cell.NumberCell />,
footer: ({ header }) => <header.AggregationFooter options={{ maximumFractionDigits: 1 }} />,
aggregationFn: "mean",
meta: { variant: "number", align: "right" },
size: 120,
}),
columnHelper.accessor("starred", {
header: t("Todo.Starred"),
cell: ({ cell }) => <cell.BooleanCell />,
meta: { variant: "boolean", align: "center" },
size: 120,
}),
columnHelper.accessor("createdAt", {
header: t("Todo.CreatedAt"),
cell: ({ cell }) => <cell.DateTimeCell />,
meta: { variant: "date" },
size: 190,
}),
]);
const table = useLayoutTable({ data, columns });
const selectedIds = table.getSelectedRowIds();
const handleOnDelete = createOptimisticAction<Array<string>>({
onMutate: (ids) => {
ids.forEach((id) => {
todos.delete(id);
});
},
mutationFn: async (ids) => {
await deleteTodos({ data: ids });
await todos.utils.refetch({ throwOnError: true });
},
});
return (
<Fullscreen>
<LayoutTable
table={table}
loading={isLoading}
showToolbar
title={t("Todo.Todos")}
onRowDoubleClick={(row) => navigate({ to: "/todos/$id", params: { id: row.id } })}
actions={
<>
<Tooltip title={t("Create")}>
<RouterIconButton to="/todos/create">
<Add fontSize="small" />
</RouterIconButton>
</Tooltip>
{selectedIds.length === 1 && (
<Tooltip title={t("Edit")}>
<RouterIconButton to="/todos/edit/$id" params={{ id: selectedIds[0] }}>
<Edit fontSize="small" />
</RouterIconButton>
</Tooltip>
)}
{selectedIds.length > 0 && (
<Tooltip title={t("Delete")}>
<IconButton
onClick={async () => {
const confirmed = await confirm(
t("DeleteConfirmation", { count: selectedIds.length }),
);
if (!confirmed) return;
try {
const transaction = handleOnDelete(selectedIds);
await transaction.isPersisted.promise;
table.resetRowSelection();
} catch (error) {
if (error instanceof Error) await alert(error.message);
}
}}
>
<Badge
badgeContent={selectedIds.length}
invisible={selectedIds.length <= 1}
color="error"
>
<Delete fontSize="small" />
</Badge>
</IconButton>
</Tooltip>
)}
</>
}
/>
</Fullscreen>
);
}
```
### Per-row actions
```tsx
columnHelper.display({
id: "actions",
cell: ({ cell }) => (
<cell.ActionsCell
actions={[
{
label: t("Edit"),
icon: <Edit fontSize="small" />,
onClick: (row) => navigate({ to: "/todos/edit/$id", params: { id: row.id } }),
},
{
label: t("Delete"),
icon: <Delete fontSize="small" />,
divider: true,
onClick: (row) => remove(row.id),
},
]}
/>
),
size: 60,
enableResizing: false,
}),
```
### Other bound components
On the header render prop: `SelectAllCell`, `ExpandAllCell`, `AggregationFooter`,
`HeaderMenu`, `HeaderSortLabel`, `HeaderResizeHandle`. On the cell one:
`SelectCell`, `ExpandCell`, `GroupCell`, `PinRowCell`, `ActionsCell` and the
formatter cells. `LayoutTable` already places the menu, the sort label and the
resize handle, so reach for those three only when building a table by hand.
### Custom cells and headers
The render prop only exposes the components registered in the library. To write your
own, read the context directly inside a plain component: `useCellContext<TValue>()`,
`useHeaderContext()` or `useTableContext()` from `wcz-layout/hooks`.
`layoutTableFeatures` and the `LayoutTableFeatures` type are the feature set
`useLayoutTable` installs — you need them only to type a hand-built table instance.
## Common Mistakes
### CRITICAL: Writing TanStack Table v8 column definitions
Wrong: `accessorKey: "name"`, `cell: (info) => info.getValue()`, `getCoreRowModel:
getCoreRowModel()`.
Correct: `columnHelper.accessor("name", { cell: ({ cell }) => <cell.ValueCell /> })`
inside `columnHelper.columns([...])`, and no row models at all.
This is v9. v8 column defs either type-error inside `columnHelper.columns()` or
silently no-op, and row models are gone.
### HIGH: Adding your own virtualizer or pager
Wrong: wrapping rows in `useVirtualizer` from `@tanstack/react-virtual`, or bolting on
a `TablePagination`.
Correct: render `<LayoutTable table={table} />` and pass `pagination` if the user wants
a pager.
`LayoutTable` already virtualizes rows and columns. A second virtualizer fights the
first over scroll position.
Use only `LayoutTable`'s `pagination` prop for its pager.