Tables¶
DataTable conventions for BO list pages. Scaffold lives in
.cursor/skills/bo-projects-create-page-table/SKILL.md; this file is the rules.
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. Server-mode tables keep the exact same wiring: withmanualFilteringTanStack only stores the selection, which the store hook forwards as query params. - Non-enum filters (a day picker) sit in the toolbar next to the search input and write through
setFilter(key, value)fromuseTableUrlSearch; the key still goes in the route schema + storeFilters. Canonical:ProjectFinancingRequestsTable/toolbar.tsx(createdOn). - Skeleton, never spinner, for table loading —
<TableSkeleton />from the DS, not a localSKELETON_ROWS.map(...)copy. 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.- One scrollport.
DataTablealready scrolls (overflow-auto). It must passcontainerClassName="overflow-visible"to<Table>so the primitive's defaultoverflow-x-auto(standalone Invest tables) doesn't nest a second X scroller — that breaks sticky header, pinned columns, and trackpad. - Client vs server mode — always through
tableModeOptions. SpreadtableModeOptions({ mode: 'client' })(default: TanStack filters/sorts/paginates the whole dataset —getSortedRowModelstays yours) ortableModeOptions({ mode: 'server', rowCount })intouseReactTable. Server mode is for lists that grow without bound (PFR: 8 500 dossiers + 20/day):datais one page,rowCountthe server total (TablePaginationreadstable.getRowCount()), and sort /q/ enum filters / page / size still come fromuseTableUrlSearch+useEnumFilterUrlSync— they just end up as query params instead of row models. The table component owns the query (its state is the query key) withplaceholderData: keepPreviousData— or a closure that drops the previous page when the tab changes the column set (projectFinancingRequestUsers); the page is a shell.utils/toListQuery.tsbuilds the contract query fromtoPageRange({ page, size }),nonEmpty(filter)andresolveServerSort({ sort, schema, fallback })(DStables/utils/server-query.ts). Only whitelisted columns are sortable (enableSorting: falseelsewhere). Export = current page. Nometa.getSumValuefooter sums (they would sum the page). Canonical:src/modules/projectFinancingRequests/components/ProjectFinancingRequestsTable/. - Reset prefs toast only if a key was deleted.
clearAllTableSettings(extraKeys?)also drops consumer persist keys (Invest:NavSidebar extraTableSettingsKeys). Never toast success on a no-op.
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-*),onRowClick(whole-row entry point to a drawer — replaces an « Ouvrir » action column; interactive children keep their own click).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.toPageRange/nonEmpty/resolveServerSort— server-list query builders: 1-based page →offset/take, empty multi-select →undefined, free-form URL sort → contract whitelist.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. Export the rows TanStack holds: the whole filtered set in client mode, the current page in server mode.tableModeOptions({ mode })— themanual*trio +autoResetPageIndex/autoResetAll: false(URL owns the page) +rowCount(server) or the filtered/paginated row models (client). Story:component-stories› Tables.TableSkeleton— the 4-row loading placeholder every list page shows while its query is pending.
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.src/modules/projectFinancingRequests/components/ProjectFinancingRequestsTable/— server mode: table owns the query,tableModeOptions({ mode: 'server' }), whitelisted sort, skeleton/error inside the section.
What NOT to do¶
- ❌ Pair
fuzzyFilterwith baregetSortedRowModel()— perfect matches stay buried. - ❌ Leave numeric / date / boolean / enum-code / action columns globally searchable — fuzzy hits inflate the count. Identity columns users paste (
id,businessId) stay in. - ❌ 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. - ❌ Set
manualPagination/manualSorting/manualFilteringby hand — one flag without the two others runs the operation twice (server + TanStack). Go throughtableModeOptions. - ❌ Read the total from
getFilteredRowModel().rows.lengthin a shared component — in server mode that is the page size. Usetable.getRowCount().