Search documentation

Search for a page or heading...

tablecn
0

Declare what users can filter by, and how each field behaves.

A field is one thing users can filter by, usually a column of your data. Every field needs a name, a label and a type:

lib/order-fields.ts
import type { FieldDefinition } from "@querycn/filter-core"

export const orderFields: FieldDefinition[] = [
  { name: "customer", label: "Customer", type: "text" },
  { name: "amount", label: "Amount", type: "number" },
  { name: "createdAt", label: "Created", type: "date" },
  { name: "shipped", label: "Shipped", type: "boolean" },
  {
    name: "status",
    label: "Status",
    type: "select",
    options: [
      { label: "Paid", value: "paid" },
      { label: "Pending", value: "pending" },
    ],
  },
]

Pass the list to the provider as fields. Fields appear in the field picker in the same order.

Define fields outside your components

Put the array at module scope, or wrap it in useMemo. A new array on every render makes the provider rebuild everything.

Choose a type

The type decides which operators users get and which input they see:

TypeUse it forOperators offered
textnames, emails, codescontains, does not contain, is, is not, starts with, ends with, is empty, is not empty
numberamounts, quantitiesis, is not, greater than, less than (or equal to), is between, is empty, is not empty
datecalendar daysis on, is after, is on or after, is before, is on or before, is between, is empty, is not empty
datetimeexact momentsis after, is on or after, is before, is on or before, is between, is empty, is not empty
timea time of day, no dateis at, is after, is at or after, is before, is at or before, is between, is empty, is not empty
booleanyes / nois
selectone choice from a listis, is not, is empty, is not empty
multiSelecttags, categoriesis any of, is none of, is empty, is not empty

The first operator in each list is picked by default, except datetime and time, which start with is between. The ids behind each label are listed in Operators.

Need something else, like a star rating? Add your own type.

Limit the operators

Give a field fewer operators with operators. They're shown in the order you list them:

Only exact match and prefix
{ name: "sku", label: "SKU", type: "text", operators: ["eq", "startsWith"] }

Change the operator picked when users choose the field with defaultOperator:

Start with 'at least'
{ name: "amount", label: "Amount", type: "number", defaultOperator: "gte" }

Operators the type doesn't have are ignored: operators: ["eq", "between"] on a text field only offers eq.

Options for select fields

select and multiSelect fields need a list of options. You can list them, or load them from your API.

A fixed list

Static options
{
  name: "status",
  label: "Status",
  type: "select",
  options: [
    { label: "Paid", value: "paid" },
    { label: "Pending", value: "pending" },
  ],
}

value is what goes in the URL and the request, label is what users see. The search box matches labels, ignoring case and accents: da nang finds Đà Nẵng.

Loaded from your API

For long or changing lists, use loadOptions. It's called with what the user typed:

lib/order-fields.ts
async function searchCustomers(search: string, signal: AbortSignal) {
  const response = await fetch(`/api/customers?q=${encodeURIComponent(search)}`, { signal })
  const customers: { id: string; name: string }[] = await response.json()
  return customers.map((c) => ({ label: c.name, value: c.id }))
}

export const orderFields: FieldDefinition[] = [
  { name: "customerId", label: "Customer", type: "select", loadOptions: searchCustomers },
]

The UI takes care of the rest:

  • The first list loads when the dropdown opens. After that, it waits until the user stops typing for 300 ms.
  • A new search cancels the previous request (that's what signal is for).
  • The old list stays on screen while the next one loads. A failed request shows a Retry button.
  • Results are cached for the page session. Call clearFieldOptionsCache(field) when the data changes (after creating a customer, on logout), or clearFieldOptionsCache() to clear everything.

Define the function outside your components: the cache belongs to the function, so a new function on every render means a new request every time.

Someone opens a link with ?customerId__eq=c_42. The options haven't loaded yet, so the chip would show c_42 instead of the customer's name. resolveLabels fixes that by fetching the missing labels:

Resolved labels
{
  name: "customerId",
  label: "Customer",
  type: "select",
  loadOptions: searchCustomers,
  resolveLabels: async (ids, signal) => {
    const response = await fetch(`/api/customers?${new URLSearchParams({ ids: ids.join(",") })}`, { signal })
    const customers: { id: string; name: string }[] = await response.json()
    return customers.map((c) => ({ label: c.name, value: c.id }))
  },
}

It's only asked about values that have no label yet. Values it doesn't return are shown as they are.

Extra data with meta

meta holds anything your own code needs, like a database column or a unit. The filter never reads it, but passes it along to serializers:

Field meta
{ name: "customer", label: "Customer", type: "text", meta: { column: "customers.full_name" } }

Fields from JSON

Without functions, a field list is plain data. It can come from a config file or an API:

order-fields.json
[
  { "name": "customer", "label": "Customer", "type": "text" },
  { "name": "amount", "label": "Amount", "type": "number", "operators": ["gte", "lte", "between"] },
  { "name": "createdAt", "label": "Created", "type": "date", "defaultOperator": "between" }
]
fields.ts
import type { FieldDefinition } from "@querycn/filter-core"

import orderFields from "./order-fields.json"

export const fields = orderFields as FieldDefinition[]

Good to know

  • Renaming a field breaks old links. The name is stored in the URL, so links with the old name lose that rule. If your backend wants another name, keep the public one and rename it in the serializer with mapRule.
  • Number fields can hold text. If a user types 12,5 (a decimal comma), the URL and the request get "12,5" instead of dropping the rule. Don't assume the value is always a number on your backend.
  • Dates are plain days. date values are strings like "2026-09-25", never Date objects, so they don't shift across time zones. Invalid days like 2026-02-30 are rejected.
  • datetime has no "is". Matching one exact moment is almost never what users want. The built-in input sends local time without a zone (2026-09-25T14:30); values with a zone (…Z, +07:00) are accepted too.
  • Times are HH:mm on a 24-hour clock. time values are strings like "09:30". Rows may hold "09:30:45" too: seconds are ignored, so it matches is at 09:30. A range doesn't wrap past midnight: between 22:00 and 06:00 is flagged as reversed and matches nothing.
  • Select values aren't checked against options. A URL can contain anything, so treat values as user input on the server.

All options

Prop

Type

If the serializer can't send an operator, it isn't offered, even when it's the defaultOperator. Then the type's default is used, or the first operator left.