Aller au contenu

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) and src/modules/royalties/.

Inputs to gather (ask if not provided)

  1. Module name (e.g., royalties, payouts) — used for folder name, query keys, i18n namespace.
  2. Route path (e.g., /projects/royalties).
  3. API endpoint (path + idtlt response validator name in @bricks-common/api-communication-bricksoffice). If the endpoint doesn't exist yet, scaffold it first under projects/common/both/api-communication-bricksoffice/src/endpoints/ and re-export from the package barrel.
  4. List item shape (which fields the table renders).
  5. Pinned columns — almost always id, businessId, name on the left and actions on the right.
  6. Per-row actions — list of icon-button cells (e.g., internal note, view, custom modal trigger).
  7. Page-level actions for the toolbar dropdown (refresh, export CSV, optional: import, download history, etc.).
  8. 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 100210 (size to longest expected value)
name (project) 230260
actions (right-pinned) 110160 (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):

  1. ColonnesColumnsIcon, opens the column-config sub-menu (drag-and-drop reorder, pin left/right, visibility checkbox, "Réinitialiser" footer). Built-in.
  2. Compact / Étendu toggle — flips Maximize2Icon / Rows3Icon via per-table compact (derived from tableId by <TableProvider>, read via useTableContext().compact / .toggleCompact). Built-in.
  3. RefreshRefreshCwIcon. Wire onRefresh={() => queryClient.invalidateQueries({ queryKey: <name>Keys.list() })}.
  4. Export CSVDownloadIcon. Built-in via exportFilename (no extension).
  5. Export XLSXFileSpreadsheetIcon. Built-in.
  6. Feature-specific items — e.g., upload modal trigger, download history. Pass via extraItems, with event.preventDefault() on onSelect for 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:

  1. pnpm --filter @bricks-common/api-communication-bricksoffice typescript:typecheck — only if you added endpoints there.
  2. pnpm --filter @bricks-common/api-communication-bricksoffice build — same condition; rebuild dist so the consumer can resolve the new exports.
  3. pnpm --filter @bricks/<consumer-app> type-check
  4. pnpm --filter @bricks/<consumer-app> build — Rolldown must pass (per project_bo_imports_field_gotchas).
  5. 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 exactly size (width/min-width/max-width all = size); whatever you pass is what renders.
  • Dist out of sync — workspace packages (api-communication, api-communication-bricksoffice, helpers) ship dist/. After editing them, rebuild them or the consumer typecheck reports phantom "no exported member" errors. tsup DTS step also fails if upstream dist .d.ts is missing — build dependencies first.
  • Cross-module imports inside the same app are fine — e.g., reusing ProjectInternalNoteModal from @/modules/projects/... inside modules/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), not bg-background (cream) — already fixed in the DS.
  • Disabled <Input> uses cursor-not-allowed + bg-muted + readable muted text — already fixed in the DS, no need to override.
  • No useMemo/useCallback reflexively — but BO consumers don't have React Compiler, so judicious memoization is allowed when profiling shows it's needed (see feedback_no_usememo_react_compiler for the mobile-app rule, which doesn't apply here).
  • Don't add zod or another validator lib to BO/consumersidonttrustlikethat is the front validator, zod lives in API only (see project_validation_split).