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 writefe/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.

Generatedfe/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 writefe/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.