Create Page Table¶
Scaffold a complete back-office list page in bricksoffice-projects (or any bricksoffice-* consumer) following the same pattern as obligations/royalties. Output: a new src/modules/<name>/ module + a src/routes/_authenticated/<path>.tsx route + the i18n keys, all wired to TanStack Query + TanStack Router + the BO design system.
When to use¶
- User asks to "create a list page" / "scaffold a table" / "add a tab next to obligations" / similar.
- Concrete reference implementations:
src/modules/projects/(obligations) andsrc/modules/royalties/.
Inputs to gather (ask if not provided)¶
- Module name (e.g.,
royalties,payouts) — used for folder name, query keys, i18n namespace. - Route path (e.g.,
/projects/royalties). - API endpoint (path + idtlt response validator name in
@bricks-common/api-communication-bricksoffice). If the endpoint doesn't exist yet, scaffold it first underprojects/common/both/api-communication-bricksoffice/src/endpoints/and re-export from the package barrel. - List item shape (which fields the table renders).
- Pinned columns — almost always
id,businessId,nameon the left andactionson the right. - Per-row actions — list of icon-button cells (e.g., internal note, view, custom modal trigger).
- Page-level actions for the toolbar dropdown (refresh, export CSV, optional: import, download history, etc.).
- Modals triggered by the page (per-row or page-level).
Module skeleton¶
src/modules/<name>/
├── components/
│ ├── <Name>Table/
│ │ ├── index.tsx # TableIdProvider + useReactTable + TableProvider + DataTable
│ │ ├── toolbar.tsx # SearchInput + Columns visibility + Actions DropdownMenu
│ │ ├── use<Name>Columns.tsx # ColumnDef<Item>[] — see "Pinning gotcha" below
│ │ └── cells/ # <Item>-specific cells (badges, action icons)
│ └── <Modal>.tsx # any feature modals (use BO <Form/> + idtltResolver)
├── pages/
│ └── <Name>Page.tsx # data hook + skeleton + error + table
├── services/
│ ├── queryKeys.ts # { all, list() }
│ ├── use<Name>.ts # useQuery
│ └── use<Mutation>.ts # one file per mutation
├── store/
│ └── use<Name>ListSearch.ts # URL state via useTableUrlSearch
├── types/
│ └── index.ts # re-export <Item> type from api-communication
└── utils/ # status maps, formatters, etc.
Required external pieces (already in BO/app)¶
- DataTable, TableIdProvider, TableProvider, useTableContext, useCompactByTableId, useTableUrlSearch, useTableColumnConfig, fuzzyFilter, parseSort/stringifySort, resolveUpdater, useCellSize, Cell/CopyCell/MoneyCell/BooleanCell/ProgressCell, exportTableToCsv, TableActionsMenu — all from
@bricks-common/bo/design-system. - DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, Button, SearchInput, Dialog/DialogContent/…, Form, idtltResolver, Input, Label, Select…, Alert, AlertDescription — same package.
- processHttpResult, formatDate, formatNullableDate, notifySuccess, notifyError —
@bricks-common/bo/core. - useHttpClient —
@bricks-common/bo/providers. - buildAdminApiErrorMessage —
@bricks-common/bo/modules/auth. - m (paraglide) —
@core/i18n. Bracket access for dotted keys:m['<name>.table.col.id']().
Step-by-step¶
1. Route file (TanStack Router file-based)¶
src/routes/_authenticated/<path>.tsx — mirrors obligations.tsx:
import { m } from '@core/i18n'
import { createFileRoute } from '@tanstack/react-router'
import { numberFromString, object, string } from 'idonttrustlikethat'
import { <Name>Page } from '@/modules/<name>/pages/<Name>Page'
const <name>ListSearch = object({
q: string.optional(),
sort: string.optional(),
page: numberFromString.optional(),
size: numberFromString.optional(),
// + any feature-specific filter params (e.g., status: array(...).optional())
})
export type <Name>ListSearch = typeof <name>ListSearch.T
export const Route = createFileRoute('/_authenticated/<path>')({
staticData: { breadcrumb: () => m.sidebar_nav_<name>() },
validateSearch: (raw) => {
const result = <name>ListSearch.validate(raw)
if (!result.ok) return {}
return result.value
},
component: <Name>Page,
})
2. URL-search store¶
src/modules/<name>/store/use<Name>ListSearch.ts:
import { useTableUrlSearch } from '@bricks-common/bo/design-system'
import { useNavigate } from '@tanstack/react-router'
import {
Route as <Name>ListRoute,
type <Name>ListSearch,
} from '@/routes/_authenticated/<path>'
export const <NAME>_PAGE_SIZES = [20, 50, 100] as const
export type <Name>PageSize = (typeof <NAME>_PAGE_SIZES)[number]
type Filters = Record<string, unknown> // or { status?: ... } if you have feature filters
const TABLE_ID = '<name>'
export const use<Name>ListSearch = () => {
const search = <Name>ListRoute.useSearch()
const navigate = useNavigate()
return useTableUrlSearch<Filters, <Name>PageSize>({
search,
onUpdate: (patch) =>
navigate({
to: '<path>',
search: (prev) => ({ ...(prev as <Name>ListSearch), ...patch }),
replace: true,
}),
defaultPageSize: 20,
pageSizes: <NAME>_PAGE_SIZES,
persistAs: TABLE_ID, // localStorage[bo:tables:<TABLE_ID>:page-size]
})
}
⚠️ Do NOT type Filters = Record<string, never> — it collapses with the base shape under intersection. Use Record<string, unknown> (or a feature-specific shape).
3. Services (queryKeys + hooks)¶
src/modules/<name>/services/queryKeys.ts:
export const <name>Keys = {
all: ['<name>'] as const,
list: () => [...<name>Keys.all, 'list'] as const,
}
Query hook:
import { processHttpResult } from '@bricks-common/bo/core'
import { useHttpClient } from '@bricks-common/bo/providers'
import { useQuery } from '@tanstack/react-query'
import { get<Name>Endpoint, type <Name>Response, <name>Response } from '@bricks-common/api-communication-bricksoffice'
import { <name>Keys } from './queryKeys'
export const use<Name> = () => {
const httpClient = useHttpClient()
return useQuery({
queryKey: <name>Keys.list(),
queryFn: (): Promise<<Name>Response> =>
processHttpResult({
responsePromise: httpClient.get(get<Name>Endpoint.request.path),
validator: <name>Response,
}),
})
}
Mutation hook (always invalidate the list on success):
return useMutation({
mutationFn: (...) => httpClient.post(...),
onSuccess: () => queryClient.invalidateQueries({ queryKey: <name>Keys.list() }),
})
4. Columns hook (use<Name>Columns.tsx)¶
⚠️ Pinning contract — read before writing column defs. Pinned cells render at exactly the size returned by getCellSize in both compact and expanded modes — DataTable clamps width/min-width/max-width all to size for pinned columns. Two consequences:
- Set maxWidth to the minimum useful width — content ellipsizes via truncate. Never set it smaller than the icons/text need to remain readable.
- Sticky left:/right: offsets are computed from size hints, so making them match actual widths is what kills the bleed-through gap. (TanStack default is 150 — leave that and a column whose content is shorter creates a 70 + px gap where scrolled-under content shows through.)
Calibrated starting values:
| Column | maxWidth |
|---|---|
id (UUID + copy icon) |
280 |
businessId |
100–210 (size to longest expected value) |
name (project) |
230–260 |
actions (right-pinned) |
110–160 (sum of icon-button widths) |
import { Cell, centsSort, CopyCell, MoneyCell, nullableDateSort, useCellSize } from '@bricks-common/bo/design-system'
import { formatDate, formatNullableDate } from '@bricks-common/bo/core'
export const use<Name>Columns = (...): ColumnDef<<Item>>[] => {
const getCellSize = useCellSize()
return useMemo<ColumnDef<<Item>>[]>(() => [
// pinned-left — explicit maxWidth required (see Pinning gotcha above)
{ id: 'id', /* … */ size: getCellSize({ compactMaxWidth: 70, maxWidth: 280 }) },
{ id: 'businessId', /* … */ size: getCellSize({ compactMaxWidth: 70, maxWidth: 110 }) },
{ id: 'name', /* … */ size: getCellSize({ maxWidth: 230 }) },
// center: regular columns, getCellSize() with compactMaxWidth only
// pinned-right
{ id: 'actions', /* … */ size: getCellSize({ compactMaxWidth: 110, maxWidth: 110 }) },
], [getCellSize, /* deps */])
}
For each column add a meta: { csv: (row) => ... } so CSV export produces useful values (numbers as strings, dates formatted, etc.).
5. Table component (<Name>Table/index.tsx)¶
Mirrors RoyaltiesTable/index.tsx exactly — outer wrapper mounts <TableIdProvider tableId={TABLE_ID} defaultCompact?>, inner component runs all hooks (useTableColumnConfig, useReactTable) then mounts <TableProvider table={table} exportFilename refresh? onResetColumns? onDefaultSortChange?> wrapping <TableSection>. Pagination: destructure onPaginationChange from useTableUrlSearch and pass straight to useReactTable({ onPaginationChange }) — never reimplement the resolveUpdater + page-size branch at the call site. Key constants:
const DEFAULT_PINNING: ColumnPinningState = {
left: ['id', 'businessId', 'name'],
right: ['actions'],
}
const DEFAULT_SORTING = [{ id: 'createdAt', desc: true }]
<DataTable> props:
- emptyMessage={m['<name>.table.empty']()}
- paginationLabels={{ rowsPerPage, pageOf, total }} — paraglide keys
- rowClassName={(row) => row.original.hasInternalNote ? 'bg-fuchsia-100/40' : ''} — if the feature has internal notes
Modal state for per-row triggers (resale, internal note, …) is lifted state in this component (useState), not Zustand/Context — see feedback_no_state_lib_for_local_modal. Pass the open/close callbacks down through column meta.
6. Toolbar (<Name>Table/toolbar.tsx) — Actions dropdown is the ONLY right-cluster button¶
The right cluster of the toolbar contains exactly one visible button: the Actions <DropdownMenu> (use <TableActionsMenu>). Colonnes (drag-reorder / pin / hide / reset), Compact toggle, Refresh, Export CSV, Export XLSX, and any feature-specific actions all live inside the dropdown — do not render them as standalone toolbar buttons.
Dropdown items in this order (mostly built-in to <TableActionsMenu>; you only pass onResetColumns={reset} from useTableColumnConfig and extraItems for feature-specific actions):
- Colonnes —
ColumnsIcon, opens the column-config sub-menu (drag-and-drop reorder, pin left/right, visibility checkbox, "Réinitialiser" footer). Built-in. - Compact / Étendu toggle — flips
Maximize2Icon/Rows3Iconvia per-table compact (derived fromtableIdby<TableProvider>, read viauseTableContext().compact/.toggleCompact). Built-in. - Refresh —
RefreshCwIcon. WireonRefresh={() => queryClient.invalidateQueries({ queryKey: <name>Keys.list() })}. - Export CSV —
DownloadIcon. Built-in viaexportFilename(no extension). - Export XLSX —
FileSpreadsheetIcon. Built-in. - Feature-specific items — e.g., upload modal trigger, download history. Pass via
extraItems, withevent.preventDefault()ononSelectfor items that should not auto-close before a follow-up modal opens.
<DropdownMenuContent align="end" className="min-w-72 p-1"> — wider menu, padded to feel "beautiful" (per user preference).
Search input on the left with 300ms debounce via useDebouncedCallback from @bricks-common-front/helpers.
7. Page composition (pages/<Name>Page.tsx)¶
import { Skeleton } from '@bricks-common/bo/design-system'
import { m } from '@core/i18n'
import type { ReactNode } from 'react'
import { <Name>Table } from '../components/<Name>Table'
import { use<Name> } from '../services/use<Name>'
const SKELETON_ROWS = [0, 1, 2, 3]
const TableSkeleton = () => (
<div className="flex flex-col gap-2">{SKELETON_ROWS.map((i) => <Skeleton key={i} className="h-10" />)}</div>
)
const Layout = ({ children }: { children: ReactNode }) => (
<div className="flex flex-col gap-4 p-6">{children}</div>
)
export const <Name>Page = () => {
const { data, isLoading, isError } = use<Name>()
if (isError) return <Layout><p className="text-destructive">{m['<name>.error_loading']()}</p></Layout>
if (isLoading || !data) return <Layout><TableSkeleton /></Layout>
return <Layout><<Name>Table data={data} /></Layout>
}
Skeleton over spinner (project rule). Errors surface as visible French text, not silent.
8. Sidebar entry¶
If the new page should be reachable from the sidebar, add a child to the matching parent in src/core/layout/AppSidebar.tsx's NAV_ITEMS. The to value's type narrows NavChildPath — add the new path to that union too.
9. i18n (messages/fr.json)¶
Add a <name>.* namespace mirroring project.* / royalty.*. Required groups:
<name>.error_loading<name>.table.search_placeholder,.table.empty<name>.table.col.<each-column>(id, business_id, project, created_at, …, actions)<name>.pagination.rows_per_page,.page_x_of_y({current}{total}),.total_n({count})<name>.toolbar_actions.menu_label("Actions"),.compact,.expand,.refresh,.export_csv, plus any feature-specific entries- Per feature: badges, modal copy, success/error toasts
Use bracket access for dotted keys: m['<name>.table.col.id']() — paraglide v2 exports them as dotted strings, snake_case (m.<name>_table_col_id()) compiles but resolves to undefined at runtime (silent breakage).
After editing messages/fr.json, the Vite plugin regenerates src/core/i18n/paraglide/ on next request — no manual codegen step.
10. Modals (if any)¶
Use <Form> from @bricks-common/bo/design-system with idtltResolver(idtltValidator) — never zod, never raw react-hook-form resolver. Submit handler is (values, { submissionId }) => Promise<void>. Validators: idonttrustlikethat, validators-as-types via typeof v.T.
For per-row modals: store selectedItem: Item | null in the table component and conditionally render <Modal item={selected} onClose={() => setSelected(null)}> once.
For Dialog: <DialogContent size="xl"> for two-section forms (resale-style), size="md" (default) otherwise. Background is bg-card (white) — that's the BO default after the design fix.
For warnings inside a modal use <Alert variant="warning"> (amber). Other variants available: info (sky), success (emerald), destructive (rose).
Verification¶
After scaffolding, run all four before reporting done:
pnpm --filter @bricks-common/api-communication-bricksoffice typescript:typecheck— only if you added endpoints there.pnpm --filter @bricks-common/api-communication-bricksoffice build— same condition; rebuild dist so the consumer can resolve the new exports.pnpm --filter @bricks/<consumer-app> type-checkpnpm --filter @bricks/<consumer-app> build— Rolldown must pass (perproject_bo_imports_field_gotchas).pnpm --filter @bricks/<consumer-app> lint— biome.
If bricksoffice-invest is also a consumer of any BO change you made, run its build too.
Then pnpm dev and check all of: sidebar link works → URL has search params after sort/page → row tint flips on internal-note save → search debounces → compact toggle in dropdown rerenders cells → CSV exports → all modals submit and refetch.
Common pitfalls (from session learnings)¶
- Pinned-column bleed-through — pinned columns need explicit
maxWidth(see §4). DataTable clamps pinned cells to exactlysize(width/min-width/max-widthall =size); whatever you pass is what renders. - Dist out of sync — workspace packages (
api-communication,api-communication-bricksoffice,helpers) shipdist/. After editing them, rebuild them or the consumer typecheck reports phantom "no exported member" errors.tsupDTS step also fails if upstream dist.d.tsis missing — build dependencies first. - Cross-module imports inside the same app are fine — e.g., reusing
ProjectInternalNoteModalfrom@/modules/projects/...insidemodules/royalties/. The "no cross-project imports" rule is between sibling apps, not modules. - Don't pin Compact / Refresh / Export CSV as standalone buttons — user preference: those go inside the Actions dropdown.
- Dialog should be
bg-card(white), notbg-background(cream) — already fixed in the DS. - Disabled
<Input>usescursor-not-allowed+bg-muted+ readable muted text — already fixed in the DS, no need to override. - No
useMemo/useCallbackreflexively — but BO consumers don't have React Compiler, so judicious memoization is allowed when profiling shows it's needed (seefeedback_no_usememo_react_compilerfor the mobile-app rule, which doesn't apply here). - Don't add zod or another validator lib to BO/consumers —
idonttrustlikethatis the front validator,zodlives in API only (seeproject_validation_split).