# 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): ```jsonnet 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): ```typescript export type AssetSearchRequest = { filter?: AssetFilterCondition | AssetFilterExpression | null; q?: string | null; sort?: Array | 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): ```typescript 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`: ```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 ( ); } ``` Columns are yours to declare — see [table configuration](table-configuration.md) for region wiring, column persistence, and saved views.