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 write — be/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:
Generated — fe/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:
Generated — fe/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 edit — fe/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.