Skip to content

BMS Data Table Guide

How to build list pages and embed tables in the BMS app. For the full component reference and design decisions, see the BMS Data Table System reference doc (in the mcc-systems repo at docs/live/bms-data-table-system.md).


1. Building a new list page

A list page needs three things: a column definition array, a page component using ListPage, and a route entry.

Step 1 — Define columns

Create a columns file (e.g. src/resources/products/productColumns.ts):

import type { ColumnDef } from '@tanstack/react-table';
import type { Product } from '@/types';

export const productColumns: ColumnDef<Product, unknown>[] = [
  {
    accessorKey: 'name',
    header: 'Name',
  },
  {
    accessorKey: 'category',
    header: 'Category',
  },
  {
    accessorKey: 'unit_price',
    header: 'Unit Price',
    cell: ({ getValue }) => `$${(getValue() as number).toFixed(2)}`,
  },
  {
    accessorKey: 'created_at',
    header: 'Created',
    meta: { sortField: 'created_at' },
    cell: ({ getValue }) => formatDate(getValue() as string, timezone), // from useDateFormatter()
  },
];

Column definition rules:

  • accessorKey must match the Supabase column name (or a path from the select string)
  • header as a string is used by CSV export; function headers work for display but are skipped in export
  • meta.sortField overrides which DB column is sorted on (defaults to accessorKey)
  • meta.width sets a fixed column width and switches the table to table-fixed layout
  • Columns without accessorKey (e.g. action buttons) are excluded from CSV export

Step 2 — Create the list page component

import { ListPage, type FilterDef } from '@/components/new/ListPage';
import { productColumns } from './productColumns';
import type { Product } from '@/types';

const filters: FilterDef[] = [
  {
    source: 'category_id',
    label: 'Category',
    reference: 'product_categories',
  },
];

export const ProductList = () => (
  <ListPage<Product>
    resource="products"
    resourceLabel="Products"
    columns={productColumns}
    defaultSort={{ field: 'name', order: 'ASC' }}
    defaultPerPage={25}
    filters={filters}
  />
);

export default ProductList;

ListPage handles everything: data fetching, pagination, sorting, filtering, row click navigation (to /{resource}/{id}), bulk delete, and CSV export.

Step 3 — Add the route

Add a lazy-loaded route in AppRouter.tsx:

const ProductList = lazyWithRetry(() => import('@/resources/products/ProductList'));

// In the route config:
{ path: '/products', element: <SuspenseRoute><ProductList /></SuspenseRoute> }

2. Embedding a SectionTable in an edit page

SectionTable is for related records within an edit page (e.g. services on an opportunity).

import { SectionTable } from '@/components/new/SectionTable';
import { serviceColumns } from './serviceColumns';

export const OpportunityServices = ({
  opportunityId,
  services,
  isLoading,
}: {
  opportunityId: string;
  services: Service[];
  isLoading: boolean;
}) => {
  const navigate = useNavigate();
  const [selectedIds, setSelectedIds] = useState<string[]>([]);

  const handleAdd = async () => {
    const { data } = await supabaseClient
      .from('services')
      .insert({ opportunity_id: opportunityId })
      .select('id')
      .single();
    if (data) navigate(`/services/${data.id}`);
  };

  const handleBulkDelete = async (ids: string[]) => {
    await supabaseClient.from('services').delete().in('id', ids);
    queryClient.invalidateQueries({ queryKey: ['opportunity-services'] });
  };

  return (
    <SectionTable<Service>
      title="Services"
      data={services}
      columns={serviceColumns}
      isLoading={isLoading}
      selectedIds={selectedIds}
      onSelectedIdsChange={setSelectedIds}
      onRowClick={(r) => navigate(`/services/${r.id}`)}
      onAdd={handleAdd}
      onBulkDelete={handleBulkDelete}
      empty="No services yet. Click Create to add one."
    />
  );
};

Differences from ListPage:

  • No pagination — all records are displayed
  • No URL sync — sort/filter state is local
  • You manage the data fetching yourself (typically via useQuery in the parent)
  • You provide onAdd, onBulkDelete, and onRowClick handlers directly

3. Adding filters

Filters let users narrow the list by foreign key columns. Define them as FilterDef[]:

const filters: FilterDef[] = [
  // Reference filter — loads options from a Supabase table
  {
    source: 'status_id',
    label: 'Status',
    reference: 'opportunity_statuses',
    referenceOptionText: 'name',    // column to display (default: 'name')
    referenceSort: 'sort_order',    // column to sort by (default: optionText)
  },
  // Static choices filter — hardcoded options
  {
    source: 'priority',
    label: 'Priority',
    choices: [
      { id: 'High', name: 'High' },
      { id: 'Medium', name: 'Medium' },
      { id: 'Low', name: 'Low' },
    ],
  },
];

For ListPage: Pass filters as a prop. The component handles everything.

For SectionTable: Pass filters, filterValues, and onFilterChange:

const [filterValues, setFilterValues] = useState<Record<string, unknown>>({});

<SectionTable
  filters={filters}
  filterValues={filterValues}
  onFilterChange={setFilterValues}
  // ... other props
/>

Note: SectionTable filters are client-side display only — you need to apply them to your data query yourself, or filter the data array before passing it.


4. Adding CSV export

CSV export is built into both ListPage and SectionTable — no setup needed.

When rows are selected, only those rows are exported; otherwise all visible rows are exported. Only columns with a string accessorKey are included — render-only columns (action buttons, status badges) are automatically excluded.

Custom export — use exportToCsv directly:

import { exportToCsv } from '@/components/new/exportCsv';

const handleCustomExport = () => {
  exportToCsv(myData, myColumns, 'custom-report.csv');
};

5. Common patterns and pitfalls

Sortable columns

A column is sortable when onSortChange is provided to DataTable and the column has either an accessorKey or a meta.sortField. If the DB column name differs from the accessor path, use meta.sortField:

{
  accessorKey: 'organisation.name',   // nested join path
  header: 'Organisation',
  meta: { sortField: 'organisation_id' },  // sort by the FK column
}

Row click and interactive elements

DataTable prevents row navigation when the user clicks on interactive elements (inputs, buttons, selects, textareas) inside a row. This is handled automatically — you do not need to add stopPropagation to interactive cells, except for the checkbox column which uses stopPropagation explicitly.

Drag-to-reorder

Pass onReorder to SectionTable or DataTable. The callback receives the full array of IDs in the new order. You are responsible for persisting the new order (typically by updating a sort_order column):

const handleReorder = async (ids: string[]) => {
  const updates = ids.map((id, i) => ({ id, sort_order: i }));
  await supabaseClient.from('tasks').upsert(updates);
  queryClient.invalidateQueries({ queryKey: ['tasks'] });
};

Bulk delete guard

Use bulkDeleteGuard on ListPage to conditionally disable delete based on the selected records:

const guard: BulkDeleteGuard = (selectedIds) => {
  if (selectedIds.some(id => lockedIds.includes(id))) {
    return { disabled: true, tooltip: 'Cannot delete locked records' };
  }
  return null; // allow delete
};

<ListPage bulkDeleteGuard={guard} ... />

Default page size

ListPage defaults to defaultPerPage={10000} via useSupabaseList — effectively "show all" when no defaultPerPage is specified. Always set an explicit defaultPerPage (e.g. 25 or 50) for tables that could have many records.

SectionTable does not paginate. If the table could have hundreds of rows, consider a sub-list page instead.

Select string for joins

When your list needs data from related tables, pass a select string:

<ListPage
  resource="opportunities"
  select="*, organisation:organisations(name), contact:contacts(first_name, last_name)"
  columns={columnsWithJoinedData}
/>

The accessor key in your column def should then match the nested path (e.g. organisation.name). Remember that meta.sortField must reference the actual DB column, not the join path.

Static filters

Use the filter prop on ListPage for filters that are always applied (e.g. filter={{ opportunity_id: opportunityId }}). These are applied as .eq() calls before any user filters.