Skip to content

Custom Filter Operators

Use a custom filter operator when the standard text and number comparisons do not express an application rule. Custom operators use the Core customFilters configuration and work with both FilterPlugin and Pro’s AdvanceFilterPlugin.

A custom operator has three parts:

  1. A stable operator ID, used by saved filter state and Pro FilterAst conditions.
  2. A columnFilterType identifying the column family that can show the operator.
  3. A synchronous predicate that returns true when a row should remain visible.

Quick start: add an operator to a built-in family

Section titled “Quick start: add an operator to a built-in family”

This operator appears with the ordinary string operators because its columnFilterType is string and the column uses filter: 'string':

import type { LogicFunction } from '@revolist/revogrid';
const endsWithCode: LogicFunction = (value, expected) => {
const suffix = String(expected ?? '')
.trim()
.toLowerCase();
if (!suffix) return true;
return String(value ?? '')
.toLowerCase()
.endsWith(suffix);
};
endsWithCode.extra = 'input';
grid.columns = [
{ prop: 'sku', name: 'SKU', filter: 'string' },
{ prop: 'name', name: 'Product', filter: 'string' },
];
grid.filter = {
customFilters: {
endsWithCode: {
columnFilterType: 'string',
name: 'Ends with code',
func: endsWithCode,
},
},
};

Open the SKU filter, choose Ends with code, and enter a suffix. Returning true for an empty input keeps dynamic editing from temporarily hiding every row.

Use a custom family when an operator should appear only on explicitly opted-in columns:

const matchesPriority: LogicFunction = (value, expected) =>
!expected || String(value).toLowerCase() === String(expected).toLowerCase();
matchesPriority.extra = 'input';
grid.columns = [
{ prop: 'priority', name: 'Priority', filter: 'ticketPriority' },
];
grid.filter = {
customFilters: {
matchesPriority: {
columnFilterType: 'ticketPriority',
name: 'Matches priority',
func: matchesPriority,
},
},
};

The family ID must match exactly. An operator registered for ticketPriority is not offered by a column configured with filter: 'string'.

Set func.extra to tell the standard filter panel how to collect the operator value:

func.extraPanel controlTypical use
'input'Text inputCodes, thresholds, and search values
'datepicker'Native date inputOne calendar-date value
Render functionApplication-defined VNode contentSelects or compound values
OmittedNo value controlValueless rules whose predicate ignores expected

For example, change the quick-start operator to a date input without replacing the filter panel:

const onOrAfter: LogicFunction = (value, expected) => {
if (!expected) return true;
return new Date(value).getTime() >= new Date(String(expected)).getTime();
};
onOrAfter.extra = 'datepicker';

A custom renderer receives the current value and controlled callbacks. Call onInput() with the next filter value; do not mutate the filter item directly:

import type { ExtraField } from '@revolist/revogrid';
const priorityControl: ExtraField = (h, context) =>
h(
'select',
{
value: context.value ?? '',
onInput: (event: Event) =>
context.onInput((event.target as HTMLSelectElement).value),
onFocus: context.onFocus,
},
[
h('option', { value: '' }, 'Any priority'),
h('option', { value: 'low' }, 'Low'),
h('option', { value: 'high' }, 'High'),
],
);
matchesPriority.extra = priorityControl;

Use include only when the entire filter popup should be restricted to a known list of operator IDs. It is global to the filter configuration, so omitting another required built-in or custom ID removes that operation from the available families.

RevoGrid calls a predicate as func(parsedValue, conditionValue, context). When the column defines cellParser, both built-in and custom comparisons receive its result:

const within: LogicFunction = (value, expected) => {
const target = Number(expected);
return !Number.isFinite(target) || Math.abs(Number(value) - target) <= 5;
};
within.extra = 'input';
grid.columns = [
{
prop: 'price',
name: 'Price',
filter: 'number',
cellParser: (model) =>
Number(String(model.price ?? '').replace(/[^0-9.-]/g, '')),
},
];
grid.filter = {
customFilters: {
withinFive: {
columnFilterType: 'number',
name: 'Within 5',
func: within,
},
},
};

Use cellParser for reusable normalization rather than repeating source conversion in every predicate. The optional third argument provides source-aware details when a rule needs them:

const isOriginalNull: LogicFunction = (_value, _expected, context) =>
context?.hasOwnProperty === true && context.sourceValue === null;

The context includes model, column, property, sourceValue, parsedValue, hasOwnProperty, and the resolved blank policy. Ordinary custom predicates receive parsedValue; Core blank operators intentionally use the original source value. See Blank Values for that distinction.

Use the operator ID in the same compatible state model as a built-in condition:

grid.filter = {
customFilters: {
endsWithCode: {
columnFilterType: 'string',
name: 'Ends with code',
func: endsWithCode,
},
},
multiFilterItems: {
sku: [
{
id: 1,
type: 'endsWithCode',
value: '-EU',
relation: 'and',
},
],
},
};

Keep customFilters present whenever restoring its conditions. Unknown operation IDs cannot be evaluated and are not a portable replacement for registering the predicate.

Pro accepts an application-defined AST operator only when the same ID is registered in customFilters. The condition value must also be JSON-safe:

import { AdvanceFilterPlugin } from '@revolist/revogrid-pro';
grid.plugins = [AdvanceFilterPlugin];
grid.filter = {
customFilters: {
endsWithCode: {
columnFilterType: 'string',
name: 'Ends with code',
func: endsWithCode,
},
},
};
const plugins = await grid.getPlugins();
const filter = plugins.find(
(plugin) => plugin instanceof AdvanceFilterPlugin,
) as AdvanceFilterPlugin;
await filter.setFilterAst({
type: 'condition',
field: 'sku',
operator: 'endsWithCode',
valueType: 'string',
value: '-EU',
});

Registering the predicate supplies both validation and execution for that custom AST operator. Without the registration, setFilterAst() rejects the tree as containing an unknown operator and leaves the previous filter active.

RequirementExtension point
Add one Boolean row test using the standard condition UIcustomFilters predicate
Normalize stored data for several operatorsColumn cellParser
Add a small input, date, or custom field beside one conditionfunc.extra
Build a purpose-specific Pro popup body that owns several operators or a structured JSON valueStructuredFilterType plus matching customFilters predicates
Load the filtered dataset from a backendCancel beforefilterapply and reload source

A custom structured type does not replace its evaluator. Its operatorIds define the UI-owned conditions, while matching customFilters entries define how those conditions evaluate. See Custom Structured Filter Types for the registry and commit contract.

Predicates are synchronous and may run once per row for every active condition. Keep them pure and fast: avoid requests, DOM work, shared-state mutation, and large allocations. Declaring a predicate async returns a Promise, not a deferred Boolean result, and therefore does not implement asynchronous filtering.

When the backend owns filtering, cancel local evaluation synchronously in beforefilterapply, serialize the requested state, and let the application’s data controller replace the source:

let latestRequest = 0;
grid.addEventListener('beforefilterapply', (event) => {
event.preventDefault();
const request = ++latestRequest;
const detail = (
event as CustomEvent<{ filterItems: Record<string, unknown[]> }>
).detail;
void fetch('/api/orders/filter', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(detail.filterItems),
})
.then((response) => response.json())
.then((rows) => {
if (request === latestRequest) grid.source = rows;
});
});

Production controllers should also cancel stale requests and guard source replacement from triggering the same remote request again. For Pro, prefer the canonical FilterAst payload when the query can contain cross-column OR, nested groups, or NOT. See Filter Events for a guarded remote example.

The direct JSON.stringify() above is appropriate for this scalar custom value. Compatible filter state from other filter families can contain values such as Set; use the persistence guidance in Filter State and Presets before treating arbitrary multiFilterItems as a wire payload.

  • Registering the operator under customFilters but using a different columnFilterType from the column’s filter value.
  • Forgetting func.extra, then expecting the standard popup to render an input.
  • Returning a truthy Promise from an async predicate instead of handling remote work through beforefilterapply.
  • Filtering formatted source strings in every predicate instead of normalizing once with cellParser.
  • Mutating rows, filter state, or external state from a predicate that can run many times.
  • Restoring a custom multiFilterItems or FilterAst operator without registering its predicate first.
  • Using a custom renderer for a complete multi-operator popup experience; use a structured filter type for that scope.
  • Putting Date, Set, functions, cycles, or non-finite numbers in a public FilterAst value; AST input must be JSON-safe.