Customization
Translate the filter, and add your own field types and operators.
Translate the UI
Every text the filter shows comes from a messages object. English is the default. To change a few texts, pass only those to the provider:
<FilterProvider
fields={orderFields}
messages={{
actions: { open: "Filters", apply: "Show results" },
operators: { contains: "includes" },
}}
/>Vietnamese is included:
import { viMessages } from "@querycn/filter-core/locales/vi"
<FilterProvider fields={orderFields} messages={viMessages} />Define the object outside your components (or in useMemo). Field and option labels come from your fields, so translate them there.
The table has its own texts: see Table customization.
All the groups you can override:
| Group | Contains |
|---|---|
operators | A label per operator id: eq: "is", between: "is between". Add custom operators here. |
operatorsByType | Labels that read better for one field type, e.g. { date: { gt: "is after" } }. |
join | where (before the first rule), and, or, toggle (accessible name of the AND/OR switch). |
actions | open, addRule, removeRule, clearAll, apply, cancel, retry, and now, ok for the time and date-time pickers. |
placeholders | field, operator, value, search, from, to, date, datetime, time, and hour, minute (names of the picker columns). |
rangeSeparator | Between the two values of a range, "–". |
counts | Functions: selected(n), more(n), activeFilters(n), e.g. activeFilters: (n) => n + " filters". |
empty | rules, fields, options: empty-state texts. |
loading, errors.loadOptions | Async option states. |
boolean | true / false labels, "Yes" / "No". |
warnings | reversedRange, conflict, unsupported. |
An operator's label is looked up in operatorsByType[fieldType], then operators, then falls back to the id itself.
Outside React, mergeMessages(enMessages, overrides) gives the same merged object, and getOperatorLabel(messages, operator, fieldType) a label.
Add a field type
The 8 built-in types cover most data. For something else, like a 1–5 star rating, register a new type. A type decides which operators are offered, which values are valid, and how rows compare in the browser:
import { createRegistry, createValueParser } from "@querycn/filter-core"
const toRating = (raw: unknown) => {
const n = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : NaN
return Number.isInteger(n) && n >= 1 && n <= 5 ? n : undefined
}
export const filterRegistry = createRegistry({
fieldTypes: [
{
id: "rating",
operators: ["eq", "gte", "lte", "isEmpty", "isNotEmpty"],
defaultOperator: "gte",
parseValue: createValueParser(toRating),
toComparable: (value) => toRating(value) ?? null,
},
],
})Then pass the registry everywhere you pass the fields:
const fields: FieldDefinition[] = [{ name: "rating", label: "Rating", type: "rating" }]
<FilterProvider fields={fields} registry={filterRegistry} /> // client
parseFilters(searchParams, { fields, registry: filterRegistry }) // server
applyFilter(rows, state, { fields, registry: filterRegistry }) // or useAppliedFilter().contextWithout the registry, a field of an unknown type offers no operators and its rules are dropped when decoded.
Prop
Type
createRegistry throws at startup if a type references an operator that doesn't exist, or a default operator that isn't in its list. Those are configuration bugs.
Finally, give the new type an input in the UI: see custom value inputs. Without one, it gets a text input.
Changing a built-in type
An entry with a built-in id overrides that type field by field. For example, to offer startsWith on selects:
import { BUILTIN_FIELD_TYPES, createRegistry } from "@querycn/filter-core"
createRegistry({
fieldTypes: [
{ ...BUILTIN_FIELD_TYPES.select, operators: [...BUILTIN_FIELD_TYPES.select.operators, "startsWith"] },
],
})To limit operators for a single field, you don't need a registry: use the field's operators.
Add an operator
An operator needs an id, a value shape (arity), and a match function if rows are filtered in the browser. This adds is not between to number fields:
import { BUILTIN_FIELD_TYPES, createRegistry } from "@querycn/filter-core"
export const filterRegistry = createRegistry({
operators: [
{
id: "notBetween",
arity: "range",
// `actual`: the row's values (empty list = no value), already through toComparable
match: (actual, expected) => {
const [from, to] = expected as [number, number]
return actual.length > 0 && actual.every((n) => typeof n === "number" && (n < from || n > to))
},
},
],
fieldTypes: [
{ ...BUILTIN_FIELD_TYPES.number, operators: [...BUILTIN_FIELD_TYPES.number.operators, "notBetween"] },
],
})Then:
-
Give it a label:
messages={{ operators: { notBetween: "is not between" } }}. Without a label, the UI shows the id. -
Tell your serializer how to send it:
jsonApiSerializersends the id as is:filter[amount][notBetween]=10&filter[amount][notBetween]=50. Rename it withoperators: { notBetween: "not_between" }.djangoSerializerneeds a lookup:lookups: { notBetween: "not_range" }, backed by a filter on your side.postgrestSerializerneeds an entry:operators: { notBetween: (rule, quote) => … }.
Until you add them, the django and PostgREST serializers'
supportsreturnsfalse, and the UI doesn't offer the operator.
Overriding a built-in operator keeps its match only if the arity stays the same.