Aller au contenu

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 (so useCellSize() in column hooks called before useReactTable can resolve compact). Inner mounts AFTER useReactTable and takes { table, exportFilename, refresh?, onResetColumns?, onDefaultSortChange? }. <DataTable>, <TableActionsMenu>, <TablePagination>, Cell, CopyCell all read everything via useTableContext() — never pass table as a prop.
  • tableId defined once per module in src/modules/<module>/constants.ts. Hoist const TABLE_ID = '…' and import it from both the table component and the store hook. Reuse for <TableIdProvider tableId>, useTableColumnConfig({ tableId }), useTableUrlSearch({ persistAs }). The dedicated constants.ts avoids the circular import (store hook ↔ table index.tsx). <TableProvider> does NOT take tableId — it inherits.
  • Compact preference is per-tableId, persisted via useCompactByTableId(tableId) (off-context) or useTableContext().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-wire onPaginationChange; don't reimplement resolveUpdater. Pass persistAs: TABLE_ID for page-size persistence (localStorage[bo:tables:<id>:page-size]). Page index is NOT persisted.
  • Column config (order + visibility + pinning + default sort) via useTableColumnConfig — persists to localStorage[bo:tables:<id>:columns], reconciles on column add/remove. Wire setters to onColumnOrderChange / onColumnVisibilityChange / onColumnPinningChange. Pass reset to <TableProvider onResetColumns> for the "Réinitialiser" footer. Don't roll local useState<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 }. Pass isPending to <SearchInput isPending> for the spinner. Don't hand-roll useState + useEffect + useDebouncedCallback.
  • Global search MUST pair globalFilterFn: fuzzyFilter with getSortedRowModel: getRankAwareSortedRowModel(). Empty query → row model unchanged. Non-empty: if any row scored EQUAL (case-insensitive trimmed equality), the view collapses to those matches only; otherwise reorders by rank with the user-chosen sort as stable tiebreaker. Bare getSortedRowModel() 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 (MoneyCell cents, counts, percent), dates, booleans, enum-code badges, action columns. Domain judgment — lwExternalId is noise on a projects table, target on a Lemonway-ops table.
  • Enum columns filter in their header — declare meta.enumFilter, wire useEnumFilterUrlSync. 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 } to useEnumFilterUrlSync and spread its { columns, columnFilters, onColumnFiltersChange } into useReactTable — it injects the filter fn and derives the whole URL wiring from meta. URL key MUST equal the column id, and the route schema + store Filters still declare each key (forgetting one silently breaks persistence, not filtering). Embedded tables (drawer) skip the hook — EcheancierTable injects withEnumFilterFn; 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…) omit options for faceted mode (+ renderOption for badge/i18n labels) and wire getFacetedRowModel + getFacetedUniqueValues on 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. See feedback_no_state_lib_for_local_modal.
  • Active row highlight is automatic — never wire it. Body rows tint on tr:hover via styles/tables.css (under @media (hover: hover)). Sets background-color AND --row-bg; pinned children consume --row-bg via cell-pinned. Never thread a clickedRowId / selectedRowId prop.
  • CSV + XLSX export via <TableProvider exportFilename="<name>"> (no extension — exporters append). Each column declares meta: { csv: (row) => ... } — both exporters use it (numbers as numbers, dates formatted).
  • refresh carries dataUpdatedAt — mandatory, may be undefined. TablePagination renders an ambient ↻ il y a 2 min indicator. Read non-reactively: queryClient.getQueryState(<queryKey>)?.dataUpdatedAt. Re-renders fire on useIsFetching flip.
  • DataTable fills its flex parent by default. Host chain MUST be <PageLayout><TableSection><DataTable />. Embedded tables (drawer/sheet/modal with own scroll) pass maxHeight explicitly.

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 size as min-width floor (so an empty column doesn't collapse). Sticky offsets from runtime ResizeObserver measuring 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) 7090
businessId (pinned-left) 100210
name / propertyName (pinned-left) 130
actions (pinned-right) 110200 (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 a row-* utility, never inline bg-*).
  • TableIdProvider — outer; { tableId, defaultCompact? }.
  • TableProvider / useTableContext<TData>() — inner; { table, exportFilename, refresh?, onResetColumns?, onDefaultSortChange? }. Throws without outer.
  • useCompactByTableId(tableId, defaultCompact?){ compact, toggle } keyed by localStorage[bo:tables:<id>:compact]. No React context.
  • useTableUrlSearch<Filters, PageSize> — sort/filter/page state from URL search params. persistAs: TABLE_ID for page-size.
  • useCellSize() — size resolver; { compactMaxWidth? }. See pinning contract.
  • Cell / CopyCell / MoneyCell / BooleanCell / ProgressCell — pre-styled cells. MoneyCell is 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 via whitespace-pre-line. isEmpty renders .
  • fuzzyFilter + getRankAwareSortedRowModel<TData>() — pair for ranked global search.
  • useEnumFilterUrlSync({ columns, search, setFilters }) — full enum-filter wiring derived from meta.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 — honor meta.csv per 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.

  1. ColonnesColumnsIcon, sub-menu hosting ColumnsConfigContent (drag-reorder/pin/visibility + Réinitialiser footer when onResetColumns is set).
  2. Compact / ÉtenduMaximize2IconRows3Icon. Reads/writes via useTableContext().
  3. RefreshRefreshCwIcon. Rendered only when <TableProvider refresh> is set. Wire invalidate + isFetching in the table component.
  4. Export CSVDownloadIcon. Reads exportFilename from context.
  5. Export XLSXFileSpreadsheetIcon. Same.
  6. 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 fuzzyFilter with bare getSortedRowModel() — 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 through useCellSize.
  • ❌ Pass exportFilename with .csv / .xlsx extension — exporters append.
  • ❌ Apply bg-card to a pinned cell to "fix" bleed-through — cell-pinned already handles it AND mirrors row state.
  • ❌ Define a new row tint with background-color only — also set --row-bg, else pinned cells won't follow.
  • ❌ Manage column visibility/pinning/order via local useStateuseTableColumnConfig owns persistence.
  • ❌ Reuse the same tableId across two distinct tables — storage keys collide.
  • ❌ Pass table as a prop to <DataTable> / <TableActionsMenu> / <TablePagination> — all read via useTableContext().
  • ❌ Mount <TableProvider> without a <TableIdProvider> ancestor — throws.
  • ❌ Wire a selectedRowId / activeRowId prop — active-row hover is pure CSS.