# Table configuration (frontend) *Layer: Frontend · how generated tables/pages are configured* List views are server-driven tables. Codegen emits one typed hook per resource (`useTable`) plus a filter catalog and saved-view builder; you compose them with ``, a [field](fields.md) catalog, and an [action](actions.md) catalog on a hand-written page. ## How it works The table hook, filter catalog, and saved-view builder are parsed from the BE OpenAPI spec and overwritten every run. You own the page — which columns to pick, which filters to surface, saved views, row/bulk actions, and gating. `` wires the filter bar and table body under two independent `` boundaries, so each loads and skeletons separately. ## The config you write The `fe` target only needs the spec location and where to write the client. **You write** — `fe/config/fe.jsonnet`: ```jsonnet { // Path of the OpenAPI 3.x spec, relative to where codegen runs. openapi_spec: "../be/specs/openapi.json", // Where the generated client lands; under src/ so Vite picks it up. output_dir: "src/_generated", } ``` `just generate` rewrites everything under `output_dir`: the fetch core (`client.gen.ts`), `types/`, `sdk/`, `queries/` (TanStack wrappers for `x-cache-key` reads), `auth.gen.ts`, and the `tables/`, `actions/`, `reports/`, and `pages/` modules. The table hook pulls its search fn from `sdk/`, types from `types/`, and `_values` query options from `queries/`. `fields/` is scaffolded once and safe to edit. ## The frontend codegen emits Each resource becomes a typed table module — regenerated every run. **Generated** — `fe/src/_generated/tables/inventory/asset.gen.ts` (do not edit): ```typescript import { buildSavedView, selectFilters, useFSHTableController, } from "@fsh/components-library"; import { getAssetValuesQueryOptions } from "../../queries/inventory/asset.gen"; import { listAssets } from "../../sdk/inventory/asset.gen"; // Filter catalog: one definition per filterable field, `as const` so // the ids are compile-checked wherever they're referenced. export const assetsTableFilters = [ { id: "category_id", label: "Category" }, { id: "status", label: "Status" }, ] as const satisfies readonly FilterDefinition[]; // Server-driven hook for POST /v1/inventory/assets/search. Binds the // search fn, cache key, keyset pagination, and the `_values` source // into the non-suspending controller; extra options pass through. export function useAssetsTable(options: AssetsTableOptions) { const { filters = "all", persistColumns = true, ...rest } = options; return useFSHTableController({ ...rest, filters: selectFilters(assetsTableFilters, filters), // Layout (show/hide + order + width) persists under this key by // default; `persistColumns: false` opts an ephemeral table out. columnStateKey: persistColumns ? "assets" : undefined, route: { cacheKey: "assets", fetchPage: listAssets, pagination: { mode: "keyset", defaultPageSize: 20, maxPageSize: 100 }, }, // Filter-bar options load lazily from the resource's `_values` // endpoint (trigram similarity + OPA) -- one source for the whole bar. valueSource: ({ fields, search, ids }) => getAssetValuesQueryOptions({ body: { fields: fields as AssetFilterField[], search, ids }, }), }); } // Builds a system "default view"; `values` is typed against the filter // catalog, so a bad filter or enum member is a compile error. export function assetsSavedView(view: { name: string; values?: NonNullable; search?: string; sort?: SavedViewSort; }): SavedView { return buildSavedView("asset", view); } ``` A resource with no filterable fields (e.g. `departments`) gets the same hook with an empty `assetsTableFilters` and no `valueSource`. The page is hand-written — columns are app-specific, so codegen writes none. **You write** — `fe/src/pages/assets/AssetListPage.tsx` (the full surface: saved views, [reports](reports.md), column editor, selection, row link, bulk actions): ```typescript import { FSHPage, FSHTableView } from "@fsh/components-library"; import { assetReportCatalog } from "../../_generated/reports/inventory/asset.gen"; import { assetsSavedView, useAssetsTable, } from "../../_generated/tables/inventory/asset.gen"; import { assetActions } from "../../actions/inventory/asset"; import { useSavedViewsProps } from "../../api/savedViews"; import { assetFields } from "../../fields/inventory/asset"; const ASSET_DEFAULT_VIEWS = [ // `values` typed against the filter catalog -- a bad enum won't compile. assetsSavedView({ name: "All", values: {} }), assetsSavedView({ name: "In repair", values: { status: ["in_repair"] } }), assetsSavedView({ name: "Available", values: { status: ["available"] } }), ]; export default function AssetListPage() { const table = useAssetsTable({ // Pick columns off the field catalog in the order you want them. columns: [ assetFields.columns.name, assetFields.columns.category, assetFields.columns.serial_number, assetFields.columns.status, ], defaultValues: { status: ["available"] }, // typed to the catalog selectionMode: "multiple", // enables the bulk bar // `category_id` buried under the bar's "Add filter" menu; `status` // stays an always-visible chip. filters: { hidden: ["category_id"] }, }); return ( row.actions, // per-row OPA list }} bulkActions={{ definitions: assetActions.bulk("all"), plausible: assetActions.useCollectionPermissions(), }} /> ); } ``` A resource that needs none of that is a thin shell — pass `columns`, a title, and per-row actions: ```typescript // fe/src/pages/departments/DepartmentListPage.tsx const table = useDepartmentsTable({ columns: [departmentFields.columns.name] }); row.actions, // omit this and every row shows every action }} />; ``` Columns come from the [field](fields.md) catalog (`defineFields` entries with a `table` block). Filter options and the `_values` endpoint are covered in [search and filters](search-and-filters.md). To gate a column or nav entry, drive its `table.hidden` / `visible` off `useCan()` (session permissions) — a UX affordance only; the BE enforces every route and OPA gates every row regardless. See [permissions](permissions.md) and [auth](auth.md). Fully-generated pages exist for fixed platform models like comms templates (`fe/src/_generated/pages/comms/*`); the app only registers the route in `src/routes.ts`. Hand-written pages are the norm because columns are app-owned.