Tables¶
DataTable conventions for BO list pages. Scaffold lives in
.cursor/skills/bo-projects-create-page-table/SKILL.md; this file is the rules. Keep in sync: changing pinning, toolbar, URL-state hook, CSV/XLSX export, or any DataTable contract in@bricks-common/bo/design-system/tables/→ update this file in the same PR.
Hard rules¶
- Two providers per table — outer
<TableIdProvider>, inner<TableProvider>. Outer carries the table identity (souseCellSize()in column hooks called beforeuseReactTablecan resolve compact). Inner mounts AFTERuseReactTableand takes{ table, exportFilename, refresh?, onResetColumns?, onDefaultSortChange? }.<DataTable>,<TableActionsMenu>,<TablePagination>,Cell,CopyCellall read everything viauseTableContext()— never passtableas a prop. tableIddefined once per module insrc/modules/<module>/constants.ts. Hoistconst TABLE_ID = '…'and import it from both the table component and the store hook. Reuse for<TableIdProvider tableId>,useTableColumnConfig({ tableId }),useTableUrlSearch({ persistAs }). The dedicatedconstants.tsavoids the circular import (store hook ↔ tableindex.tsx).<TableProvider>does NOT taketableId— it inherits.- Compact preference is per-
tableId, persisted viauseCompactByTableId(tableId)(off-context) oruseTableContext().compact(inside<TableProvider>). Default étendu; opt in with<TableIdProvider tableId defaultCompact={true}>. - URL state via
useTableUrlSearch— sort/filter/page/page-size persist in TanStack Router search params. Returns a ready-to-wireonPaginationChange; don't reimplementresolveUpdater. PasspersistAs: TABLE_IDfor page-size persistence (localStorage[bo:tables:<id>:page-size]). Page index is NOT persisted. - Column config (order + visibility + pinning + default sort) via
useTableColumnConfig— persists tolocalStorage[bo:tables:<id>:columns], reconciles on column add/remove. Wire setters toonColumnOrderChange/onColumnVisibilityChange/onColumnPinningChange. Passresetto<TableProvider onResetColumns>for the "Réinitialiser" footer. Don't roll localuseState<VisibilityState>/useState<ColumnPinningState>— bypasses persistence. - Toolbar right cluster = exactly one visible button: the Actions
<DropdownMenu>. Colonnes, Compact, Refresh, Export CSV, Export XLSX, and feature actions live inside the dropdown — never standalone toolbar buttons. - Search input on the left, driven by
useSearchDraft({ value, onChange })— owns draft + 300ms debounce +startTransition. Returns{ draft, handleChange, isPending }. PassisPendingto<SearchInput isPending>for the spinner. Don't hand-rolluseState+useEffect+useDebouncedCallback. - Global search MUST pair
globalFilterFn: fuzzyFilterwithgetSortedRowModel: getRankAwareSortedRowModel(). Empty query → row model unchanged. Non-empty: if any row scoredEQUAL(case-insensitive trimmed equality), the view collapses to those matches only; otherwise reorders by rank with the user-chosen sort as stable tiebreaker. BaregetSortedRowModel()leaves perfect matches buried. - Opt noisy columns out of global filter with
enableGlobalFilter: false. Include columns the user reads as text (businessId, name, email, paste-able IDs). Opt out: numeric (MoneyCellcents, counts, percent), dates, booleans, enum-code badges, action columns. Domain judgment —lwExternalIdis noise on a projects table, target on a Lemonway-ops table. - Enum columns filter in their header — declare
meta.enumFilter, wireuseEnumFilterUrlSync.meta: { enumFilter: { options } }(value + badge label) auto-renders the multi-select checkbox popover; never thread a filter prop or add a toolbar dropdown. URL-synced tables pass{ columns, search, setFilters }touseEnumFilterUrlSyncand spread its{ columns, columnFilters, onColumnFiltersChange }intouseReactTable— it injects the filter fn and derives the whole URL wiring from meta. URL key MUST equal the column id, and the route schema + storeFiltersstill declare each key (forgetting one silently breaks persistence, not filtering). Embedded tables (drawer) skip the hook —EcheancierTableinjectswithEnumFilterFn; TanStack keeps filter state internal. Booleans filter as'true'/'false'option values (the filter fn String-coerces); nullable enums normalize null at the accessor (e.g.?? 'ongoing') — never put null in options. Free-form value sets (countries, KYC…) omitoptionsfor faceted mode (+renderOptionfor badge/i18n labels) and wiregetFacetedRowModel+getFacetedUniqueValueson the instance. Valid only while the table is client-filtered — a server-paginated table must move filters into query params instead. - Skeleton, never spinner, for table loading. Errors via paraglide (
m['<name>.error_loading']()). - Per-row modal state lifted in the table component (
useState<Item | null>), passed through column meta. No Zustand / Context. Seefeedback_no_state_lib_for_local_modal. - Active row highlight is automatic — never wire it. Body rows tint on
tr:hoverviastyles/tables.css(under@media (hover: hover)). Setsbackground-colorAND--row-bg; pinned children consume--row-bgviacell-pinned. Never thread aclickedRowId/selectedRowIdprop. - CSV + XLSX export via
<TableProvider exportFilename="<name>">(no extension — exporters append). Each column declaresmeta: { csv: (row) => ... }— both exporters use it (numbers as numbers, dates formatted). refreshcarriesdataUpdatedAt— mandatory, may beundefined.TablePaginationrenders an ambient↻ il y a 2 minindicator. Read non-reactively:queryClient.getQueryState(<queryKey>)?.dataUpdatedAt. Re-renders fire onuseIsFetchingflip.DataTablefills its flex parent by default. Host chain MUST be<PageLayout>→<TableSection>→<DataTable />. Embedded tables (drawer/sheet/modal with own scroll) passmaxHeightexplicitly.
Canonical outer/inner shape:
// src/modules/royalties/constants.ts
export const TABLE_ID = 'royalties'
// src/modules/royalties/components/RoyaltiesTable/index.tsx
export const RoyaltiesTable = (props: Props) => (
<TableIdProvider tableId={TABLE_ID}>
<RoyaltiesTableInner {...props} />
</TableIdProvider>
)
const RoyaltiesTableInner = ({ data }: Props) => {
const queryClient = useQueryClient()
const isFetching = useIsFetching({ queryKey: royaltyKeys.list() }) > 0
const dataUpdatedAt = queryClient.getQueryState(royaltyKeys.list())?.dataUpdatedAt
const columns = useRoyaltiesColumns(/* … */)
const { setDefaultSort, reset: resetColumnConfig, /* … */ } = useTableColumnConfig({
tableId: TABLE_ID, columnIds, defaults: { pinning: DEFAULT_PINNING },
})
const table = useReactTable<Royalty>({ /* … */ })
return (
<TableProvider
table={table}
exportFilename="royalties"
refresh={{ isFetching, dataUpdatedAt, invalidate: () => queryClient.invalidateQueries({ queryKey: royaltyKeys.list() }) }}
onResetColumns={resetColumnConfig}
onDefaultSortChange={setDefaultSort}
>
<TableSection>
<Toolbar /* … */ />
<DataTable emptyMessage={m['…']()} />
</TableSection>
</TableProvider>
)
}
Pinning contract¶
useCellSize returns a compact-mode width per cell. DataTable layers behavior:
- Compact: every cell clamped to
size(width / minWidth / maxWidth). Sticky offsets from TanStack's declared-size math. - Étendu: every cell content-driven. Pinned cells keep
sizeasmin-widthfloor (so an empty column doesn't collapse). Sticky offsets from runtimeResizeObservermeasuring leaf headers, written into--pinned-left-<colId>/--pinned-right-<colId>on<table>.
Consequences:
- Always go through useCellSize — never write a literal size: <number>. Specify compactMaxWidth when default COMPACT_CELL_WIDTH (100) isn't right.
- No "fill target" anymore. Order DEFAULT_PINNING.left for reading order; no special last-cell behaviour. Étendu unpinned cells distribute extra width via table-auto.
- cell-pinned mirrors row state via var(--row-bg) — don't apply bg-card yourself.
Calibrated starting values:
| Column | compactMaxWidth |
|---|---|
id (UUID + copy) |
70–90 |
businessId (pinned-left) |
100–210 |
name / propertyName (pinned-left) |
130 |
actions (pinned-right) |
110–200 (sum of icon-button widths) |
export const useProjectsColumns = (...): ColumnDef<Project>[] => {
const getCellSize = useCellSize()
return useMemo<ColumnDef<Project>[]>(() => [
{ id: 'businessId', size: getCellSize({ compactMaxWidth: 100 }), /* … */ },
{ id: 'name', size: getCellSize({ compactMaxWidth: 130 }), /* … */ },
{ id: 'createdAt', size: getCellSize(), /* … */ },
{ id: 'actions', size: getCellSize({ compactMaxWidth: 200 }), /* … */ },
], [getCellSize, /* … */])
}
const DEFAULT_PINNING: ColumnPinningState = {
left: ['id', 'businessId', 'name'],
right: ['actions'],
}
const DEFAULT_SORTING = [{ id: 'createdAt', desc: true }]
Reusable primitives (from @bricks-common/bo/design-system)¶
DataTable— main component; key props:emptyMessage,rowClassName(computed from row data, applied to<tr>— return arow-*utility, never inlinebg-*).TableIdProvider— outer;{ tableId, defaultCompact? }.TableProvider/useTableContext<TData>()— inner;{ table, exportFilename, refresh?, onResetColumns?, onDefaultSortChange? }. Throws without outer.useCompactByTableId(tableId, defaultCompact?)—{ compact, toggle }keyed bylocalStorage[bo:tables:<id>:compact]. No React context.useTableUrlSearch<Filters, PageSize>— sort/filter/page state from URL search params.persistAs: TABLE_IDfor page-size.useCellSize()— size resolver;{ compactMaxWidth? }. See pinning contract.Cell/CopyCell/MoneyCell/BooleanCell/ProgressCell— pre-styled cells.MoneyCellis click-to-copy (plain FR-comma euros, e.g.5200,3— toast feedback); footer sums are not.TruncatedCell— long-text + DS tooltip. Overflow-detected (<TruncatedCell>{text}</TruncatedCell>) or explicit (<TruncatedCell tooltip={fullList}>{shortLabel}</TruncatedCell>). Newlines viawhitespace-pre-line.isEmptyrenders—.fuzzyFilter+getRankAwareSortedRowModel<TData>()— pair for ranked global search.useEnumFilterUrlSync({ columns, search, setFilters })— full enum-filter wiring derived frommeta.enumFilter; lower-level pieces (enumFilterFn,withEnumFilterFn,buildColumnFilters/readColumnFilter) exported for embedded tables.parseSort/stringifySort/resolveUpdater— sort URL encoders.centsSort/nullableDateSort— column sort fns.useTableColumnConfig({ tableId, columnIds, defaults })— order/visibility/pinning + default sort + persistence + reconciliation. Returns slices, setters,defaultSort+setDefaultSort,reset.exportTableToCsv/exportTableToXlsx— honormeta.csvper column.
Highlight utilities (cell + row)¶
Tone bundles in projects/common/back-office/src/design-system/styles/tables.css. Use class names in meta.cellClassName / rowClassName — never hand-roll bg-* font-* text-* at the call site (intent lost in magic numbers).
| Class | Level | Use when |
|---|---|---|
cell-highlight-warning |
cell | Overdue or attention-needed (amber) |
cell-highlight-success |
cell | Positive accumulation worth flagging (emerald) |
cell-highlight-alert |
cell | Below acceptable floor, or échéancier « Pénalités Bricks » (rose) |
row-muted-past |
row | Inactive / past row (slate + muted text) |
row-highlight-internal-note |
row | Row carrying an internal note (soft fuchsia) |
meta: { cellClassName: (r) => (r.amountDue > 0 ? 'cell-highlight-warning' : undefined) }
rowClassName={(row) => (row.original.echeancePeriod < today ? 'row-muted-past' : '')}
Row tints MUST set both background-color and --row-bg (opaque via color-mix(..., var(--color-card))), else pinned cells won't follow. Cell highlights paint as td backgrounds over the row tint — no guard needed.
Don't double-encode row state. If a cell conveys row-level state (InternalNoteCell tinting its button), don't also tint the row via rowClassName.
Toolbar dropdown items — fixed order¶
Each item has a leading lucide icon + className="gap-3 px-3 py-2 text-sm". Use event.preventDefault() on items that should not auto-close before a follow-up modal opens.
- Colonnes —
ColumnsIcon, sub-menu hostingColumnsConfigContent(drag-reorder/pin/visibility + Réinitialiser footer whenonResetColumnsis set). - Compact / Étendu —
Maximize2Icon↔Rows3Icon. Reads/writes viauseTableContext(). - Refresh —
RefreshCwIcon. Rendered only when<TableProvider refresh>is set. Wireinvalidate+isFetchingin the table component. - Export CSV —
DownloadIcon. ReadsexportFilenamefrom context. - Export XLSX —
FileSpreadsheetIcon. Same. - Feature-specific items — via
<TableActionsMenu extraItems={…}>.
<DropdownMenuContent align="end" className="min-w-72 p-1">.
Canonical examples¶
src/modules/royalties/components/RoyaltiesTable/— outer/inner shape +extraItems(yearly update + download history).src/modules/projects/components/ProjectsTable/(obligations) — same shape with per-column enum filters (status + visibility).src/modules/wiresToBeAssigned/components/WiresToBeAssignedTable/— same shape, default étendu.
What NOT to do¶
- ❌ Pair
fuzzyFilterwith baregetSortedRowModel()— perfect matches stay buried. - ❌ Leave UUID / numeric / date / boolean / enum-code / action columns globally searchable — fuzzy hits inflate the count.
- ❌ Literal
size: <number>on a column — route throughuseCellSize. - ❌ Pass
exportFilenamewith.csv/.xlsxextension — exporters append. - ❌ Apply
bg-cardto a pinned cell to "fix" bleed-through —cell-pinnedalready handles it AND mirrors row state. - ❌ Define a new row tint with
background-coloronly — also set--row-bg, else pinned cells won't follow. - ❌ Manage column visibility/pinning/order via local
useState—useTableColumnConfigowns persistence. - ❌ Reuse the same
tableIdacross two distinct tables — storage keys collide. - ❌ Pass
tableas a prop to<DataTable>/<TableActionsMenu>/<TablePagination>— all read viauseTableContext(). - ❌ Mount
<TableProvider>without a<TableIdProvider>ancestor — throws. - ❌ Wire a
selectedRowId/activeRowIdprop — active-row hover is pure CSS.