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:
- A stable operator ID, used by saved filter state and Pro
FilterAstconditions. - A
columnFilterTypeidentifying the column family that can show the operator. - A synchronous predicate that returns
truewhen 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'.
Choose the value control
Section titled “Choose the value control”Set func.extra to tell the standard filter panel how to collect the operator value:
func.extra | Panel control | Typical use |
|---|---|---|
'input' | Text input | Codes, thresholds, and search values |
'datepicker' | Native date input | One calendar-date value |
| Render function | Application-defined VNode content | Selects or compound values |
| Omitted | No value control | Valueless 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.
Parsed values and evaluation context
Section titled “Parsed values and evaluation context”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.
Restore and combine custom conditions
Section titled “Restore and combine custom conditions”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.
Use the operator in Filter AST
Section titled “Use the operator in Filter AST”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.
Predicate or structured filter type?
Section titled “Predicate or structured filter type?”| Requirement | Extension point |
|---|---|
| Add one Boolean row test using the standard condition UI | customFilters predicate |
| Normalize stored data for several operators | Column cellParser |
| Add a small input, date, or custom field beside one condition | func.extra |
| Build a purpose-specific Pro popup body that owns several operators or a structured JSON value | StructuredFilterType plus matching customFilters predicates |
| Load the filtered dataset from a backend | Cancel 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.
Keep asynchronous work outside predicates
Section titled “Keep asynchronous work outside predicates”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.
Common mistakes
Section titled “Common mistakes”- Registering the operator under
customFiltersbut using a differentcolumnFilterTypefrom the column’sfiltervalue. - Forgetting
func.extra, then expecting the standard popup to render an input. - Returning a truthy
Promisefrom anasyncpredicate instead of handling remote work throughbeforefilterapply. - 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
multiFilterItemsorFilterAstoperator 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 publicFilterAstvalue; AST input must be JSON-safe.