Search documentation

Search for a page or heading...

tablecn
0

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:

Override messages
<FilterProvider
  fields={orderFields}
  messages={{
    actions: { open: "Filters", apply: "Show results" },
    operators: { contains: "includes" },
  }}
/>

Vietnamese is included:

Vietnamese locale
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:

GroupContains
operatorsA label per operator id: eq: "is", between: "is between". Add custom operators here.
operatorsByTypeLabels that read better for one field type, e.g. { date: { gt: "is after" } }.
joinwhere (before the first rule), and, or, toggle (accessible name of the AND/OR switch).
actionsopen, addRule, removeRule, clearAll, apply, cancel, retry, and now, ok for the time and date-time pickers.
placeholdersfield, operator, value, search, from, to, date, datetime, time, and hour, minute (names of the picker columns).
rangeSeparatorBetween the two values of a range, "–".
countsFunctions: selected(n), more(n), activeFilters(n), e.g. activeFilters: (n) => n + " filters".
emptyrules, fields, options: empty-state texts.
loading, errors.loadOptionsAsync option states.
booleantrue / false labels, "Yes" / "No".
warningsreversedRange, 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:

lib/filter-registry.ts
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:

Usage
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().context

Without 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:

Extended select type
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:

lib/filter-registry.ts
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:

  1. Give it a label: messages={{ operators: { notBetween: "is not between" } }}. Without a label, the UI shows the id.

  2. Tell your serializer how to send it:

    • jsonApiSerializer sends the id as is: filter[amount][notBetween]=10&filter[amount][notBetween]=50. Rename it with operators: { notBetween: "not_between" }.
    • djangoSerializer needs a lookup: lookups: { notBetween: "not_range" }, backed by a filter on your side.
    • postgrestSerializer needs an entry: operators: { notBetween: (rule, quote) => … }.

    Until you add them, the django and PostgREST serializers' supports returns false, and the UI doesn't offer the operator.

Overriding a built-in operator keeps its match only if the arity stays the same.