UNPKG

wcz-layout

Version:

276 lines (244 loc) 10.4 kB
--- 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.