Search & filters

Layer: Backend · the list/query layer over a resource

List querying splits into three orthogonal concerns — free-text search (a fuzzy q box), structured filters (narrow by an exact attribute), and ordering + pagination — all declared on the list op through list.searchable(...).

Note

Free-text search and per-field filters are different tools. Search (q) fuzzy-matches a few identifier-ish columns for a “type a few characters” box. Filters narrow by an exact attribute value. A resource can have either, both, or neither.

How it works

The list op compiles to a POST /search + _values endpoint pair (keyset pagination), the request/response schemas, and the FE filter catalog + table hook. You still own the model columns, the trigram indexes that keep search off sequential scans, and the default representation — the shared label/value shape reused by _values, ref autocomplete, and self filters.

The config you write

You writebe/config/inventory_resources/asset.jsonnet (search + a self/ref/enum filtered list):

local fields = import "be/fields.libsonnet";
local list = import "be/operations/list.libsonnet";

{
  model: "inventory.models.Asset",
  route_prefix: "/assets",

  // Free-text `q`: pg_trgm word-similarity OR'd with an ILIKE substring
  // fallback across these columns -- typo- and substring-tolerant, for
  // short identifier-ish fields. `global_search: true` unions the asset
  // into the project-wide `POST /v1/search` palette (OPA-filtered).
  // Bare `search: {}` = q matches the first str field of `default`.
  // Add a GIN index per column with codegen_database's trigram_indexes(...)
  // splatted into the model's schema_items -- else search is a seq scan.
  search: { fields: ["name", "serial_number"], global_search: true },

  representation_roles: { default: "default", opa: "authz" },

  operations: [
    { name: "get", representation: "resource" },
    list.searchable(
      representation="list_item",       // declared rep -> the list-row shape
      filters=[
        // self: keys off this resource's own pk. Powers `ref` autocomplete
        // elsewhere and bulk `op: "in"` row ops; NOT a user-facing chip.
        // Requires representation_roles.default. Ops: eq, in.
        { name: "id", values: "self" },
        // ref: autocompletes against the TARGET's `_values`
        // (/inventory/categories/_values); the target needs a `default` rep.
        // Ops: eq, in.
        { name: "category_id", values: "ref", ref_resource: "category" },
        // enum: choices come from the Python StrEnum (dotted path, not the
        // FE enum name) and inline into the FE catalog. Ops: eq, in.
        { name: "status", values: "enum", enum: "inventory.models.AssetStatus" },
        // Other kinds: `bool` toggle (eq); `literal` scalar with a `type:`
        // (eq/gt/gte/lt/lte). Override any with `operators: [...]`.
      ],
      // Sortable fields -- independent of `filters`; a field can be
      // sortable without being filterable. `default_order` picks the
      // default ("name" = ascending; { name, dir: "desc" } for newest-first).
      // A filter entry can carry `order: true` to add itself here.
      order=["name"],
      default_order="name",
      // Keyset by default (page size 20, max 100, cursor=pk). Override with
      // paginate={ default_page_size: 50, max_page_size: 200 }, or
      // paginate=false to disable.
    ),
  ],
}

The frontend codegen emits

Codegen bakes the filter/sort/pagination catalog into the generated table module — no runtime discovery fetch. First the search request + filter types:

Generatedfe/src/_generated/types/inventory/asset.gen.ts (do not edit):

export type AssetSearchRequest = {
  filter?: AssetFilterCondition | AssetFilterExpression | null;
  q?: string | null;
  sort?: Array<AssetSortClause> | null;
  cursor?: string | null;
  page_size?: number;
};

export type AssetFilterCondition = {
  field: "id" | "category_id" | "status";
  op?: "eq" | "neq" | "gt" | "gte" | "lt" | "lte"
     | "contains" | "starts_with" | "in" | "is_null";
  value?: unknown;
};

export type AssetFilterField = "id" | "category_id" | "status";  // trigram-matchable columns
export type AssetSortField = "name";                             // the `order=[...]` enum

Then the filter catalog + table hook. id (a self filter) is not surfaced as a user chip — only category_id and status are:

Generatedfe/src/_generated/tables/inventory/asset.gen.ts (do not edit):

export const assetsTableFilters = [
  { id: "category_id", label: "Category" },
  { id: "status", label: "Status" },
] as const satisfies readonly FilterDefinition[];

export function useAssetsTable(options: AssetsTableOptions) {
  const { filters = "all", persistColumns = true, ...rest } = options;
  return useFSHTableController({
    ...rest,
    filters: selectFilters(assetsTableFilters, filters),
    columnStateKey: persistColumns ? "assets" : undefined,
    route: {
      cacheKey: "assets",
      fetchPage: listAssets,
      pagination: { mode: "keyset", defaultPageSize: 20, maxPageSize: 100 },
    },
    // One value source for the whole bar: the open picker fetches one
    // field, global search fetches across all of them -- both via _values.
    valueSource: ({ fields, search, ids }) =>
      getAssetValuesQueryOptions({ body: { fields, search, ids } }),
  });
}

Every value-set filter (ref, enum, self-on-searchable) is BE-powered — options load lazily from POST /_values through the single valueSource; a ref drives autocomplete against its target, an enum’s members type defaultValues (status?: readonly AssetStatus[]). The list page wires the hook — pick which filters show, seed defaults, set the search box:

Scaffolded, safe to editfe/src/pages/assets/AssetListPage.tsx:

import { FSHPage, FSHTableView } from "@fsh/components-library";

import { assetsSavedView, useAssetsTable } from "../../_generated/tables/inventory/asset.gen";
import { useSavedViewsProps } from "../../api/savedViews";
import { assetFields } from "../../fields/inventory/asset";

export default function AssetListPage() {
  const table = useAssetsTable({
    columns: [assetFields.columns.name, assetFields.columns.category, assetFields.columns.status],
    defaultValues: { status: ["available"] },     // typed to the enum
    selectionMode: "multiple",
    // `category_id` buried behind the bar's "Add filter" menu; `status` stays a visible chip.
    filters: { hidden: ["category_id"] },
  });

  return (
    <FSHPage title="Assets">
      <FSHTableView
        table={table}
        // Saved views: `values` is the same typed shape as `defaultValues` --
        // a bad enum member is a compile error.
        savedViews={useSavedViewsProps("asset", {
          defaultViews: [
            assetsSavedView({ name: "All", values: {} }),
            assetsSavedView({ name: "In repair", values: { status: ["in_repair"] } }),
          ],
        })}
        searchPlaceholder="Search assets..."
        emptyState={{ title: "No assets yet" }}
      />
    </FSHPage>
  );
}

Columns are yours to declare — see table configuration for region wiring, column persistence, and saved views.