Table configuration (frontend)¶
Layer: Frontend · how generated tables/pages are configured
List views are server-driven tables. Codegen emits one typed hook per resource
(use<Resource>Table) plus a filter catalog and saved-view builder; you compose
them with <FSHTableView>, a field catalog, and an
action 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.
<FSHTableView> wires the filter bar and table body under two independent
<Suspense> 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:
{
// 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):
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<AssetsTableOptions["defaultValues"]>;
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, column editor, selection, row link, bulk actions):
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 (
<FSHPage title="Assets" actions={assetActions.useCollection("all")} back>
<FSHTableView
table={table}
savedViews={useSavedViewsProps("asset", { defaultViews: ASSET_DEFAULT_VIEWS })}
reports={assetReportCatalog.props("all", { resolvePayload: /* ... */ })}
aria-label="Assets"
searchPlaceholder="Search assets..."
emptyState={{ title: "No assets yet" }}
columnEditor
to={assetActions.rowHref} // row link
rowActions={{
definitions: assetActions.object("all"),
getRowActions: (row) => row.actions, // per-row OPA list
}}
bulkActions={{
definitions: assetActions.bulk("all"),
plausible: assetActions.useCollectionPermissions(),
}}
/>
</FSHPage>
);
}
A resource that needs none of that is a thin shell — pass columns, a title, and
per-row actions:
// fe/src/pages/departments/DepartmentListPage.tsx
const table = useDepartmentsTable({ columns: [departmentFields.columns.name] });
<FSHTableView
table={table}
aria-label="Departments"
searchPlaceholder="Search departments..."
emptyState={{ title: "No departments yet" }}
rowActions={{
definitions: departmentActions.object("all"),
getRowActions: (row) => row.actions, // omit this and every row shows every action
}}
/>;
Columns come from the field catalog (defineFields entries with a
table block). Filter options and the _values endpoint are covered in
search and filters. 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 and auth.
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.