Data Grid & Bulk Operations¶
The dashboard ships a single reusable table primitive — <DataGrid/> — that every list surface uses
instead of hand-rolling a <table>. It provides sorting, faceted filtering, pagination, row selection,
audited bulk actions, and CSV export, all dependency-free (no TanStack Table / virtualization lib).
- Feature: F17 · Design: ADR 0061 (
design/adr/0061-dashboard-data-grid-bulk-ops.md) · Spec:design/vision/specs/F17-data-grid-bulk-ops.md - Frontend:
platform/services/dashboard/frontend/src/lib/datagrid.ts(pure helpers) +components/DataGrid.tsx(component) - First consumer: the FinOps cost-by-model table (
pages/Finops.tsx)
Using the grid¶
import { DataGrid } from '@/components/DataGrid'
import type { Column } from '@/lib/datagrid'
const COLUMNS: Column<Row>[] = [
{ key: 'model', header: 'Model', accessor: (r) => r.model, sortable: true },
{ key: 'env', header: 'Env', accessor: (r) => r.env, facet: true }, // facet → filter chips
{ key: 'cost', header: 'Cost', accessor: (r) => r.cost, // numeric → sorts numerically
render: (r) => usd(r.cost), sortable: true }, // render → shown formatted
]
<DataGrid
columns={COLUMNS}
rows={rows}
getRowId={(r) => r.id}
initialSort={[{ col: 'cost', dir: 'desc' }]}
storageKey="finops-cost" // persists density preference
label="cost-by-model" // used for the a11y label + CSV filename
bulkActions={[{ label: 'Delete', destructive: true, onRun: (rows) => api.delete(rows) }]}
/>
Column<T> fields¶
| Field | Purpose |
|---|---|
accessor |
Scalar used for sort / filter / facet / CSV export (and display, unless render is set) |
render |
Optional cell renderer for display only — keeps numeric sort correct on a formatted column |
sortable |
Enables the sortable header (click cycles asc → desc → unsorted) |
facet |
Marks a facet dimension — renders filter chips with live value counts |
The query envelope (R2)¶
The pure layer models a list query and its result:
ListQuery { sort?: SortSpec[]; filters?: Record<string,string>; page?: number; pageSize?: number }
ListResult { rows: T[]; total: number; pageCount: number; facets: Record<string, Record<string, number>> }
queryRows(rows, columns, query) runs the full client-side pipeline: filter → facets (over filtered)
→ sort → paginate. This is deliberately the same shape a BFF list endpoint should return, so a grid
can migrate from client-side to server-side execution without changing the API contract.
encodeQuery/decodeQuery round-trip a ListQuery through URL search params (?sort=cost:desc&f.env=prod&page=1),
so a filtered/sorted view is shareable and deep-linkable.
Bulk actions are never silent (R4)¶
When rows are selected a bulk-action bar appears. Clicking an action does not fire it — it shows an
inline Confirm / Cancel prompt first. On confirm, the action's onRun(selectedRows) runs against the
selected rows (it may be async), then the selection clears. Backends that mutate should authorize and
audit server-side; the grid guarantees no accidental mass mutation from the UI.
Export (R6)¶
The Export CSV control serializes the current filtered + sorted view (all pages, not just the
visible page) via toCsv, which RFC-4180-escapes commas/quotes/newlines. The download filename is the
label.
Notes & limits¶
This slice ships the client-side grid (sort/filter/facet/paginate/select/bulk/export) and its first adoption. Deferred (tracked in the plan): row virtualization for very large sets, server-side query execution against a BFF endpoint, saved views (R5), column show/hide + reorder, and Parquet export.
See docs/dashboard/architecture.md for
the design diagram.