Canonical Filter AST
FilterAst is RevoGrid Pro’s validated, JSON-safe Boolean filter tree. Use the same tree for local row evaluation, the grouped visual editor, saved views, and remote requests.
New to ASTs? Start with What Is a Filter AST? for a plain-language explanation, business use cases, and a smaller first example. This page is the complete technical contract.
Choose the simplest state model that fits. Use
multiFilterItemsfor ordinary per-column filtering and existing column controls. UseFilterAstfor cross-columnOR, nested groups,NOT, canonical saved views, or a transport-safe remote query. Canonical execution requiresAdvanceFilterPlugin; CoreFilterPluginremains the right choice for ordinary Core filtering.
Try the complete lifecycle
Section titled “Try the complete lifecycle”Source code
import { defineCustomElements } from '@revolist/revogrid/loader';
import type {
FilterAst,
FilterAstChangeEventDetail,
FilterAstErrorEventDetail,
} from '@revolist/revogrid-pro';
import { currentTheme } from '../composables/useRandomData';
import {
captureWorkbenchReference,
createFilterAst,
createFilterAstRows,
createTransportEnvelope,
filterAstColumns,
filterAstConfig,
filterAstPlugins,
filterAstScenarios,
formatJson,
getAdvanceFilterPlugin,
invalidFilterAst,
roundTripFilterAst,
type AstScenarioId,
type SupportTicket,
} from './FilterAst.shared';
import './filter-ast.scss';
defineCustomElements();
const { isDark } = currentTheme();
export function load(parentSelector: string, rows?: SupportTicket[]) {
const reference = captureWorkbenchReference();
const source = rows?.length ? rows : createFilterAstRows(reference);
const container = document.createElement('section');
const grid = document.createElement('revo-grid');
let activeScenario: AstScenarioId = 'nested';
let quickSearch = '';
let preserveQuickSearch = false;
let visibleCount = source.length;
let appliedAst: FilterAst | undefined;
let savedAst: FilterAst | undefined;
let eventDetail: FilterAstChangeEventDetail | undefined;
let diagnostics: FilterAstErrorEventDetail['diagnostics'] = [];
let validationSummary = 'No validation errors.';
let disposed = false;
container.className = 'filter-ast-workbench';
container.dataset.testid = 'filter-ast-workbench';
container.innerHTML = `
<div class="filter-ast-workbench__toolbar">
<div class="filter-ast-workbench__scenario">
<strong>Canonical Filter AST Workbench</strong>
<p data-scenario-description></p>
</div>
<div class="filter-ast-workbench__actions" aria-label="AST scenarios"></div>
<label>
<span class="sr-only">Quick search</span>
<input class="filter-ast-workbench__search" type="search" placeholder="Quick search tickets" aria-label="Quick search tickets" />
</label>
<label class="filter-ast-workbench__preserve">
<input data-preserve-quick-search type="checkbox" />
Preserve quick search
</label>
<button class="rv-btn rv-btn-secondary" data-action="restore" type="button">Restore saved</button>
<button class="rv-btn rv-btn-secondary" data-action="invalid" type="button">Try invalid AST</button>
<button class="rv-btn rv-btn-secondary" data-action="clear" type="button">Clear</button>
</div>
<p class="filter-ast-workbench__hint">
Showing <strong data-testid="filter-ast-count"></strong> rows.
Presets call <code>setFilterAst()</code>; quick search uses the public <code>quickFilter</code> property.
</p>
<div data-grid-slot></div>
<div class="filter-ast-workbench__inspectors">
<article class="filter-ast-workbench__panel">
<h3>Applied AST</h3>
<p class="filter-ast-workbench__meta">Effective defensive clone returned by <code>getFilterAst()</code>.</p>
<pre data-testid="filter-ast-applied"></pre>
</article>
<article class="filter-ast-workbench__panel">
<h3>Event / transport</h3>
<p class="filter-ast-workbench__meta" data-transport-meta></p>
<pre data-testid="filter-ast-transport"></pre>
</article>
<article class="filter-ast-workbench__panel">
<h3>Validation</h3>
<p class="filter-ast-workbench__meta" data-validation-summary></p>
<pre data-testid="filter-ast-validation"></pre>
</article>
</div>
`;
const scenarioDescription = container.querySelector<HTMLElement>(
'[data-scenario-description]',
)!;
const actions = container.querySelector<HTMLElement>(
'.filter-ast-workbench__actions',
)!;
const quickSearchInput = container.querySelector<HTMLInputElement>(
'.filter-ast-workbench__search',
)!;
const preserveInput = container.querySelector<HTMLInputElement>(
'[data-preserve-quick-search]',
)!;
const countOutput = container.querySelector<HTMLElement>(
'[data-testid="filter-ast-count"]',
)!;
const appliedOutput = container.querySelector<HTMLElement>(
'[data-testid="filter-ast-applied"]',
)!;
const transportMeta = container.querySelector<HTMLElement>(
'[data-transport-meta]',
)!;
const transportOutput = container.querySelector<HTMLElement>(
'[data-testid="filter-ast-transport"]',
)!;
const validationSummaryOutput = container.querySelector<HTMLElement>(
'[data-validation-summary]',
)!;
const validationOutput = container.querySelector<HTMLElement>(
'[data-testid="filter-ast-validation"]',
)!;
const gridSlot = container.querySelector<HTMLElement>('[data-grid-slot]')!;
for (const scenario of filterAstScenarios) {
const button = document.createElement('button');
button.className = 'rv-btn rv-btn-secondary';
button.type = 'button';
button.dataset.scenario = scenario.id;
button.textContent = scenario.label;
actions.append(button);
}
function render() {
const description =
filterAstScenarios.find((item) => item.id === activeScenario)
?.description ?? '';
scenarioDescription.replaceChildren(
document.createTextNode(
`${description} Open a column filter and choose `,
),
Object.assign(document.createElement('b'), { textContent: 'Groups' }),
document.createTextNode(' to edit the same tree visually.'),
);
countOutput.textContent = `${visibleCount} / ${source.length}`;
appliedOutput.textContent = formatJson(appliedAst);
transportMeta.replaceChildren(
document.createTextNode('Origin: '),
Object.assign(document.createElement('b'), {
textContent: eventDetail?.origin ?? '—',
}),
document.createTextNode(' · Projectable: '),
Object.assign(document.createElement('b'), {
textContent: String(eventDetail?.projectable ?? false),
}),
);
transportOutput.textContent = eventDetail
? formatJson(createTransportEnvelope(eventDetail))
: 'Apply a scenario to capture an event.';
validationSummaryOutput.textContent = validationSummary;
validationOutput.textContent = diagnostics.length
? formatJson(diagnostics)
: 'No diagnostics.';
}
async function syncState() {
const plugin = await getAdvanceFilterPlugin(grid);
appliedAst = plugin.getFilterAst();
visibleCount = (await grid.getVisibleSource()).length;
render();
}
async function applyScenario(id: AstScenarioId) {
activeScenario = id;
diagnostics = [];
validationSummary = 'No validation errors.';
const plugin = await getAdvanceFilterPlugin(grid);
await plugin.setFilterAst(createFilterAst(id, reference), {
preserveQuickFilter: preserveQuickSearch,
});
if (!preserveQuickSearch) {
quickSearch = '';
quickSearchInput.value = '';
grid.quickFilter = undefined;
}
savedAst ??= roundTripFilterAst(plugin.getFilterAst());
await syncState();
}
function onScenarioClick(event: Event) {
const id = (event.target as HTMLElement).closest<HTMLButtonElement>(
'[data-scenario]',
)?.dataset.scenario;
if (id) void applyScenario(id as AstScenarioId);
}
function onQuickSearch() {
quickSearch = quickSearchInput.value;
grid.quickFilter = quickSearch
? { text: quickSearch, debounceMs: 0 }
: undefined;
}
function onPreserveQuickSearch() {
preserveQuickSearch = preserveInput.checked;
}
async function restoreSaved() {
if (!savedAst) return;
await (
await getAdvanceFilterPlugin(grid)
).setFilterAst(roundTripFilterAst(savedAst));
await syncState();
}
async function tryInvalid() {
const beforeAst = formatJson(
(await getAdvanceFilterPlugin(grid)).getFilterAst(),
);
const beforeCount = (await grid.getVisibleSource()).length;
try {
await (
await getAdvanceFilterPlugin(grid)
).setFilterAst(invalidFilterAst as unknown as FilterAst);
} catch {
await syncState();
validationSummary =
beforeAst === formatJson(appliedAst) && beforeCount === visibleCount
? 'Rejected atomically; the AST and visible rows were preserved.'
: 'Rejected, but the preservation check changed unexpectedly.';
render();
}
}
async function clearAst() {
quickSearch = '';
quickSearchInput.value = '';
grid.quickFilter = undefined;
await (await getAdvanceFilterPlugin(grid)).setFilterAst(undefined);
await syncState();
}
function onToolbarClick(event: Event) {
const action = (event.target as HTMLElement).closest<HTMLButtonElement>(
'[data-action]',
)?.dataset.action;
if (action === 'restore') void restoreSaved();
if (action === 'invalid') void tryInvalid();
if (action === 'clear') void clearAst();
}
function onAstChange(event: Event) {
eventDetail = (event as CustomEvent<FilterAstChangeEventDetail>).detail;
void syncState();
}
function onAstError(event: Event) {
diagnostics = (event as CustomEvent<FilterAstErrorEventDetail>).detail
.diagnostics;
render();
}
function onAfterFilter() {
void syncState();
}
grid.className = 'filter-ast-workbench__grid cell-border';
grid.theme = isDark() ? 'darkCompact' : 'compact';
grid.columns = filterAstColumns;
grid.plugins = filterAstPlugins;
grid.filter = filterAstConfig;
grid.stretch = 'last';
grid.hideAttribution = true;
grid.addEventListener('filterastchange', onAstChange);
grid.addEventListener('filterasterror', onAstError);
grid.addEventListener('afterfilterapply', onAfterFilter);
actions.addEventListener('click', onScenarioClick);
quickSearchInput.addEventListener('input', onQuickSearch);
preserveInput.addEventListener('change', onPreserveQuickSearch);
container
.querySelector('.filter-ast-workbench__toolbar')!
.addEventListener('click', onToolbarClick);
const themeObserver = new MutationObserver(() => {
grid.theme = isDark() ? 'darkCompact' : 'compact';
});
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme', 'class'],
});
gridSlot.replaceWith(grid);
document.querySelector(parentSelector)?.append(container);
grid.source = source;
render();
void customElements.whenDefined('revo-grid').then(() => {
if (!disposed) return applyScenario('nested');
});
return () => {
disposed = true;
themeObserver.disconnect();
actions.removeEventListener('click', onScenarioClick);
quickSearchInput.removeEventListener('input', onQuickSearch);
preserveInput.removeEventListener('change', onPreserveQuickSearch);
container
.querySelector('.filter-ast-workbench__toolbar')
?.removeEventListener('click', onToolbarClick);
grid.removeEventListener('filterastchange', onAstChange);
grid.removeEventListener('filterasterror', onAstError);
grid.removeEventListener('afterfilterapply', onAfterFilter);
container.remove();
};
}
<template>
<section class="filter-ast-workbench" data-testid="filter-ast-workbench">
<div class="filter-ast-workbench__toolbar">
<div class="filter-ast-workbench__scenario">
<strong>Canonical Filter AST Workbench</strong>
<p>
{{ scenarioDescription }} Open a column filter and choose
<b>Groups</b> to edit the same tree visually.
</p>
</div>
<div class="filter-ast-workbench__actions" aria-label="AST scenarios">
<button
v-for="scenario in filterAstScenarios"
:key="scenario.id"
class="rv-btn rv-btn-secondary"
type="button"
@click="applyScenario(scenario.id)"
>
{{ scenario.label }}
</button>
</div>
<label>
<span class="sr-only">Quick search</span>
<input
v-model="quickSearch"
class="filter-ast-workbench__search"
type="search"
placeholder="Quick search tickets"
aria-label="Quick search tickets"
@input="applyQuickSearch"
/>
</label>
<label class="filter-ast-workbench__preserve">
<input v-model="preserveQuickSearch" type="checkbox" />
Preserve quick search
</label>
<button
class="rv-btn rv-btn-secondary"
type="button"
@click="restoreSaved"
>
Restore saved
</button>
<button class="rv-btn rv-btn-secondary" type="button" @click="tryInvalid">
Try invalid AST
</button>
<button class="rv-btn rv-btn-secondary" type="button" @click="clearAst">
Clear
</button>
</div>
<p class="filter-ast-workbench__hint">
Showing
<strong data-testid="filter-ast-count"
>{{ visibleCount }} / {{ totalCount }}</strong
>
rows. Presets call <code>setFilterAst()</code>; quick search uses the
public <code>quickFilter</code> property.
</p>
<RevoGrid
ref="gridRef"
class="filter-ast-workbench__grid cell-border"
:theme="isDark ? 'darkCompact' : 'compact'"
:columns="columns"
:source="rows"
:plugins="plugins"
:filter="filter"
stretch="last"
hide-attribution
@aftergridinit="initializeWorkbench"
/>
<div class="filter-ast-workbench__inspectors">
<article class="filter-ast-workbench__panel">
<h3>Applied AST</h3>
<p class="filter-ast-workbench__meta">
Effective defensive clone returned by <code>getFilterAst()</code>.
</p>
<pre data-testid="filter-ast-applied">{{ appliedOutput }}</pre>
</article>
<article class="filter-ast-workbench__panel">
<h3>Event / transport</h3>
<p class="filter-ast-workbench__meta">
Origin: <b>{{ eventOrigin }}</b> · Projectable:
<b>{{ projectable }}</b>
</p>
<pre data-testid="filter-ast-transport">{{ transportOutput }}</pre>
</article>
<article class="filter-ast-workbench__panel">
<h3>Validation</h3>
<p class="filter-ast-workbench__meta">{{ validationSummary }}</p>
<pre data-testid="filter-ast-validation">{{ validationOutput }}</pre>
</article>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
import RevoGrid from '@revolist/vue3-datagrid';
import type {
FilterAst,
FilterAstChangeEventDetail,
FilterAstErrorEventDetail,
} from '@revolist/revogrid-pro';
import { currentThemeVue } from '../composables/useRandomData';
import {
captureWorkbenchReference,
createFilterAst,
createFilterAstRows,
createTransportEnvelope,
filterAstColumns,
filterAstConfig,
filterAstPlugins,
filterAstScenarios,
formatJson,
getAdvanceFilterPlugin,
invalidFilterAst,
roundTripFilterAst,
type AstScenarioId,
} from './FilterAst.shared';
import './filter-ast.scss';
const props = defineProps<{ rows?: ReturnType<typeof createFilterAstRows> }>();
const { isDark } = currentThemeVue();
const gridRef = ref<InstanceType<typeof RevoGrid>>();
const reference = captureWorkbenchReference();
const rows = ref(
props.rows?.length ? props.rows : createFilterAstRows(reference),
);
const columns = filterAstColumns;
const plugins = filterAstPlugins;
const filter = filterAstConfig;
const activeScenario = ref<AstScenarioId>('nested');
const quickSearch = ref('');
const preserveQuickSearch = ref(false);
const visibleCount = ref(rows.value.length);
const appliedAst = ref<FilterAst>();
const savedAst = ref<FilterAst>();
const eventDetail = ref<FilterAstChangeEventDetail>();
const validation = ref<FilterAstErrorEventDetail['diagnostics']>([]);
const validationSummary = ref('No validation errors.');
let grid: HTMLRevoGridElement | undefined;
const totalCount = computed(() => rows.value.length);
const scenarioDescription = computed(
() =>
filterAstScenarios.find((item) => item.id === activeScenario.value)
?.description ?? '',
);
const appliedOutput = computed(() => formatJson(appliedAst.value));
const eventOrigin = computed(() => eventDetail.value?.origin ?? '—');
const projectable = computed(() =>
String(eventDetail.value?.projectable ?? false),
);
const transportOutput = computed(() =>
eventDetail.value
? formatJson(createTransportEnvelope(eventDetail.value))
: 'Apply a scenario to capture an event.',
);
const validationOutput = computed(() =>
validation.value.length ? formatJson(validation.value) : 'No diagnostics.',
);
function element() {
const candidate = gridRef.value as
| (HTMLRevoGridElement & { $el?: HTMLRevoGridElement })
| undefined;
return candidate?.$el ?? candidate;
}
async function syncState() {
if (!grid) return;
const plugin = await getAdvanceFilterPlugin(grid);
appliedAst.value = plugin.getFilterAst();
visibleCount.value = (await grid.getVisibleSource()).length;
}
async function applyScenario(id: AstScenarioId) {
if (!grid) return;
activeScenario.value = id;
validation.value = [];
validationSummary.value = 'No validation errors.';
const plugin = await getAdvanceFilterPlugin(grid);
await plugin.setFilterAst(createFilterAst(id, reference), {
preserveQuickFilter: preserveQuickSearch.value,
});
if (!preserveQuickSearch.value) {
quickSearch.value = '';
grid.quickFilter = undefined;
}
savedAst.value ??= roundTripFilterAst(plugin.getFilterAst());
await syncState();
}
function applyQuickSearch(event: Event) {
if (!grid) return;
const text = (event.target as HTMLInputElement).value;
grid.quickFilter = text ? { text, debounceMs: 0 } : undefined;
}
async function restoreSaved() {
if (!grid || !savedAst.value) return;
await (
await getAdvanceFilterPlugin(grid)
).setFilterAst(roundTripFilterAst(savedAst.value));
await syncState();
}
async function tryInvalid() {
if (!grid) return;
const beforeAst = formatJson(
(await getAdvanceFilterPlugin(grid)).getFilterAst(),
);
const beforeCount = (await grid.getVisibleSource()).length;
try {
await (
await getAdvanceFilterPlugin(grid)
).setFilterAst(invalidFilterAst as unknown as FilterAst);
} catch {
await syncState();
validationSummary.value =
beforeAst === formatJson(appliedAst.value) &&
beforeCount === visibleCount.value
? 'Rejected atomically; the AST and visible rows were preserved.'
: 'Rejected, but the preservation check changed unexpectedly.';
}
}
async function clearAst() {
if (!grid) return;
quickSearch.value = '';
grid.quickFilter = undefined;
await (await getAdvanceFilterPlugin(grid)).setFilterAst(undefined);
await syncState();
}
function onAstChange(event: Event) {
eventDetail.value = (event as CustomEvent<FilterAstChangeEventDetail>).detail;
void syncState();
}
function onAstError(event: Event) {
validation.value = (
event as CustomEvent<FilterAstErrorEventDetail>
).detail.diagnostics;
}
function onAfterFilter() {
void syncState();
}
async function initializeWorkbench() {
if (grid) return;
await customElements.whenDefined('revo-grid');
await nextTick();
const candidate = element();
if (!candidate || typeof candidate.getPlugins !== 'function') return;
grid = candidate;
grid.addEventListener('filterastchange', onAstChange);
grid.addEventListener('filterasterror', onAstError);
grid.addEventListener('afterfilterapply', onAfterFilter);
grid.source = rows.value;
await applyScenario('nested');
}
onMounted(() => {
void initializeWorkbench();
});
onBeforeUnmount(() => {
grid?.removeEventListener('filterastchange', onAstChange);
grid?.removeEventListener('filterasterror', onAstError);
grid?.removeEventListener('afterfilterapply', onAfterFilter);
});
</script>
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { RevoGrid } from '@revolist/react-datagrid';
import type {
FilterAst,
FilterAstChangeEventDetail,
FilterAstErrorEventDetail,
} from '@revolist/revogrid-pro';
import { currentTheme } from '../composables/useRandomData';
import {
captureWorkbenchReference,
createFilterAst,
createFilterAstRows,
createTransportEnvelope,
filterAstColumns,
filterAstConfig,
filterAstPlugins,
filterAstScenarios,
formatJson,
getAdvanceFilterPlugin,
invalidFilterAst,
roundTripFilterAst,
type AstScenarioId,
type SupportTicket,
} from './FilterAst.shared';
import './filter-ast.scss';
type FilterAstDemoProps = {
rows?: SupportTicket[];
};
const { isDark } = currentTheme();
function FilterAstDemo({ rows }: FilterAstDemoProps) {
const gridRef = useRef<HTMLRevoGridElement>(null);
const preserveQuickSearchRef = useRef(false);
const reference = useMemo(() => captureWorkbenchReference(), []);
const source = useMemo(
() => (rows?.length ? rows : createFilterAstRows(reference)),
[reference, rows],
);
const columns = useMemo(() => [...filterAstColumns], []);
const plugins = useMemo(() => [...filterAstPlugins], []);
const filter = useMemo(() => ({ ...filterAstConfig }), []);
const [activeScenario, setActiveScenario] = useState<AstScenarioId>('nested');
const [quickSearch, setQuickSearch] = useState('');
const [preserveQuickSearch, setPreserveQuickSearch] = useState(false);
const [visibleCount, setVisibleCount] = useState(source.length);
const [appliedAst, setAppliedAst] = useState<FilterAst>();
const [savedAst, setSavedAst] = useState<FilterAst>();
const [eventDetail, setEventDetail] = useState<FilterAstChangeEventDetail>();
const [diagnostics, setDiagnostics] = useState<
FilterAstErrorEventDetail['diagnostics']
>([]);
const [validationSummary, setValidationSummary] = useState(
'No validation errors.',
);
const [darkTheme, setDarkTheme] = useState(isDark);
const syncState = useCallback(async () => {
const grid = gridRef.current;
if (!grid) return;
const plugin = await getAdvanceFilterPlugin(grid);
setAppliedAst(plugin.getFilterAst());
setVisibleCount((await grid.getVisibleSource()).length);
}, []);
const applyScenario = useCallback(
async (id: AstScenarioId) => {
const grid = gridRef.current;
if (!grid) return;
setActiveScenario(id);
setDiagnostics([]);
setValidationSummary('No validation errors.');
const plugin = await getAdvanceFilterPlugin(grid);
await plugin.setFilterAst(createFilterAst(id, reference), {
preserveQuickFilter: preserveQuickSearchRef.current,
});
if (!preserveQuickSearchRef.current) {
setQuickSearch('');
grid.quickFilter = undefined;
}
setSavedAst(
(current) => current ?? roundTripFilterAst(plugin.getFilterAst()),
);
await syncState();
},
[reference, syncState],
);
const restoreSaved = useCallback(async () => {
const grid = gridRef.current;
if (!grid || !savedAst) return;
await (
await getAdvanceFilterPlugin(grid)
).setFilterAst(roundTripFilterAst(savedAst));
await syncState();
}, [savedAst, syncState]);
const tryInvalid = useCallback(async () => {
const grid = gridRef.current;
if (!grid) return;
const plugin = await getAdvanceFilterPlugin(grid);
const beforeAst = formatJson(plugin.getFilterAst());
const beforeCount = (await grid.getVisibleSource()).length;
try {
await plugin.setFilterAst(invalidFilterAst as unknown as FilterAst);
} catch {
const preservedAst = plugin.getFilterAst();
const preservedCount = (await grid.getVisibleSource()).length;
setAppliedAst(preservedAst);
setVisibleCount(preservedCount);
setValidationSummary(
beforeAst === formatJson(preservedAst) && beforeCount === preservedCount
? 'Rejected atomically; the AST and visible rows were preserved.'
: 'Rejected, but the preservation check changed unexpectedly.',
);
}
}, []);
const clearAst = useCallback(async () => {
const grid = gridRef.current;
if (!grid) return;
setQuickSearch('');
grid.quickFilter = undefined;
await (await getAdvanceFilterPlugin(grid)).setFilterAst(undefined);
await syncState();
}, [syncState]);
const applyQuickSearch = useCallback((value: string) => {
setQuickSearch(value);
const grid = gridRef.current;
if (grid)
grid.quickFilter = value ? { text: value, debounceMs: 0 } : undefined;
}, []);
useEffect(() => {
const observer = new MutationObserver(() => setDarkTheme(isDark()));
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme', 'class'],
});
return () => observer.disconnect();
}, []);
useEffect(() => {
const grid = gridRef.current;
if (!grid) return;
const onAstChange = (event: Event) => {
setEventDetail((event as CustomEvent<FilterAstChangeEventDetail>).detail);
void syncState();
};
const onAstError = (event: Event) => {
setDiagnostics(
(event as CustomEvent<FilterAstErrorEventDetail>).detail.diagnostics,
);
};
const onAfterFilter = () => void syncState();
grid.addEventListener('filterastchange', onAstChange);
grid.addEventListener('filterasterror', onAstError);
grid.addEventListener('afterfilterapply', onAfterFilter);
let disposed = false;
void customElements.whenDefined('revo-grid').then(() => {
if (!disposed) return applyScenario('nested');
});
return () => {
disposed = true;
grid.removeEventListener('filterastchange', onAstChange);
grid.removeEventListener('filterasterror', onAstError);
grid.removeEventListener('afterfilterapply', onAfterFilter);
};
}, [applyScenario, syncState]);
const scenarioDescription =
filterAstScenarios.find((item) => item.id === activeScenario)
?.description ?? '';
const transportOutput = eventDetail
? formatJson(createTransportEnvelope(eventDetail))
: 'Apply a scenario to capture an event.';
return (
<section
className="filter-ast-workbench"
data-testid="filter-ast-workbench"
>
<div className="filter-ast-workbench__toolbar">
<div className="filter-ast-workbench__scenario">
<strong>Canonical Filter AST Workbench</strong>
<p>
{scenarioDescription} Open a column filter and choose <b>Groups</b>{' '}
to edit the same tree visually.
</p>
</div>
<div
className="filter-ast-workbench__actions"
aria-label="AST scenarios"
>
{filterAstScenarios.map((scenario) => (
<button
key={scenario.id}
className="rv-btn rv-btn-secondary"
type="button"
onClick={() => void applyScenario(scenario.id)}
>
{scenario.label}
</button>
))}
</div>
<label>
<span className="sr-only">Quick search</span>
<input
className="filter-ast-workbench__search"
type="search"
value={quickSearch}
placeholder="Quick search tickets"
aria-label="Quick search tickets"
onChange={(event) => applyQuickSearch(event.target.value)}
/>
</label>
<label className="filter-ast-workbench__preserve">
<input
type="checkbox"
checked={preserveQuickSearch}
onChange={(event) => {
preserveQuickSearchRef.current = event.target.checked;
setPreserveQuickSearch(event.target.checked);
}}
/>
Preserve quick search
</label>
<button
className="rv-btn rv-btn-secondary"
type="button"
onClick={() => void restoreSaved()}
>
Restore saved
</button>
<button
className="rv-btn rv-btn-secondary"
type="button"
onClick={() => void tryInvalid()}
>
Try invalid AST
</button>
<button
className="rv-btn rv-btn-secondary"
type="button"
onClick={() => void clearAst()}
>
Clear
</button>
</div>
<p className="filter-ast-workbench__hint">
Showing{' '}
<strong data-testid="filter-ast-count">
{visibleCount} / {source.length}
</strong>{' '}
rows. Presets call <code>setFilterAst()</code>; quick search uses the
public <code>quickFilter</code> property.
</p>
<RevoGrid
ref={gridRef}
className="filter-ast-workbench__grid cell-border"
theme={darkTheme ? 'darkCompact' : 'compact'}
columns={columns}
source={source}
plugins={plugins}
filter={filter}
stretch="last"
hide-attribution
/>
<div className="filter-ast-workbench__inspectors">
<article className="filter-ast-workbench__panel">
<h3>Applied AST</h3>
<p className="filter-ast-workbench__meta">
Effective defensive clone returned by <code>getFilterAst()</code>.
</p>
<pre data-testid="filter-ast-applied">{formatJson(appliedAst)}</pre>
</article>
<article className="filter-ast-workbench__panel">
<h3>Event / transport</h3>
<p className="filter-ast-workbench__meta">
Origin: <b>{eventDetail?.origin ?? '—'}</b> · Projectable:{' '}
<b>{String(eventDetail?.projectable ?? false)}</b>
</p>
<pre data-testid="filter-ast-transport">{transportOutput}</pre>
</article>
<article className="filter-ast-workbench__panel">
<h3>Validation</h3>
<p className="filter-ast-workbench__meta">{validationSummary}</p>
<pre data-testid="filter-ast-validation">
{diagnostics.length ? formatJson(diagnostics) : 'No diagnostics.'}
</pre>
</article>
</div>
</section>
);
}
export default FilterAstDemo;
import {
AfterViewInit,
Component,
ElementRef,
Input,
OnDestroy,
ViewChild,
ViewEncapsulation,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { RevoGrid } from '@revolist/angular-datagrid';
import type {
FilterAst,
FilterAstChangeEventDetail,
FilterAstErrorEventDetail,
} from '@revolist/revogrid-pro';
import {
captureWorkbenchReference,
createFilterAst,
createFilterAstRows,
createTransportEnvelope,
filterAstColumns,
filterAstConfig,
filterAstPlugins,
filterAstScenarios,
formatJson,
getAdvanceFilterPlugin,
invalidFilterAst,
roundTripFilterAst,
type AstScenarioId,
type SupportTicket,
} from './FilterAst.shared';
function isDarkTheme() {
return (
document.documentElement.getAttribute('data-theme') === 'dark' ||
document.documentElement.classList.contains('dark')
);
}
@Component({
selector: 'filter-ast-workbench-grid',
standalone: true,
imports: [CommonModule, RevoGrid],
template: `
<section class="filter-ast-workbench" data-testid="filter-ast-workbench">
<div class="filter-ast-workbench__toolbar">
<div class="filter-ast-workbench__scenario">
<strong>Canonical Filter AST Workbench</strong>
<p>
{{ scenarioDescription }} Open a column filter and choose
<b>Groups</b> to edit the same tree visually.
</p>
</div>
<div class="filter-ast-workbench__actions" aria-label="AST scenarios">
<button
*ngFor="let scenario of scenarios"
class="rv-btn rv-btn-secondary"
type="button"
(click)="applyScenario(scenario.id)"
>
{{ scenario.label }}
</button>
</div>
<label>
<span class="sr-only">Quick search</span>
<input
class="filter-ast-workbench__search"
type="search"
placeholder="Quick search tickets"
aria-label="Quick search tickets"
[value]="quickSearch"
(input)="applyQuickSearch($event)"
/>
</label>
<label class="filter-ast-workbench__preserve">
<input
type="checkbox"
[checked]="preserveQuickSearch"
(change)="setPreserveQuickSearch($event)"
/>
Preserve quick search
</label>
<button
class="rv-btn rv-btn-secondary"
type="button"
(click)="restoreSaved()"
>
Restore saved
</button>
<button
class="rv-btn rv-btn-secondary"
type="button"
(click)="tryInvalid()"
>
Try invalid AST
</button>
<button
class="rv-btn rv-btn-secondary"
type="button"
(click)="clearAst()"
>
Clear
</button>
</div>
<p class="filter-ast-workbench__hint">
Showing
<strong data-testid="filter-ast-count"
>{{ visibleCount }} / {{ totalCount }}</strong
>
rows. Presets call <code>setFilterAst()</code>; quick search uses the
public <code>quickFilter</code> property.
</p>
<revo-grid
#gridRef
class="filter-ast-workbench__grid cell-border"
[theme]="theme"
[columns]="columns"
[source]="source"
[plugins]="plugins"
[filter]="filter"
stretch="last"
[hideAttribution]="true"
></revo-grid>
<div class="filter-ast-workbench__inspectors">
<article class="filter-ast-workbench__panel">
<h3>Applied AST</h3>
<p class="filter-ast-workbench__meta">
Effective defensive clone returned by <code>getFilterAst()</code>.
</p>
<pre data-testid="filter-ast-applied">{{ appliedOutput }}</pre>
</article>
<article class="filter-ast-workbench__panel">
<h3>Event / transport</h3>
<p class="filter-ast-workbench__meta">
Origin: <b>{{ eventOrigin }}</b> · Projectable:
<b>{{ projectable }}</b>
</p>
<pre data-testid="filter-ast-transport">{{ transportOutput }}</pre>
</article>
<article class="filter-ast-workbench__panel">
<h3>Validation</h3>
<p class="filter-ast-workbench__meta">{{ validationSummary }}</p>
<pre data-testid="filter-ast-validation">{{ validationOutput }}</pre>
</article>
</div>
</section>
`,
styleUrls: ['./filter-ast.scss'],
encapsulation: ViewEncapsulation.None,
})
export class FilterAstWorkbenchGridComponent
implements AfterViewInit, OnDestroy
{
@ViewChild('gridRef', { read: ElementRef })
gridRef!: ElementRef<HTMLRevoGridElement>;
private readonly reference = captureWorkbenchReference();
private grid?: HTMLRevoGridElement;
private themeObserver?: MutationObserver;
private savedAst?: FilterAst;
private eventDetail?: FilterAstChangeEventDetail;
private validation: FilterAstErrorEventDetail['diagnostics'] = [];
source: SupportTicket[] = createFilterAstRows(this.reference);
readonly columns = filterAstColumns;
readonly plugins = filterAstPlugins;
readonly filter = filterAstConfig;
readonly scenarios = filterAstScenarios;
activeScenario: AstScenarioId = 'nested';
quickSearch = '';
preserveQuickSearch = false;
visibleCount = this.source.length;
appliedAst?: FilterAst;
validationSummary = 'No validation errors.';
theme: 'compact' | 'darkCompact' = isDarkTheme() ? 'darkCompact' : 'compact';
@Input() set rows(value: SupportTicket[] | undefined) {
if (value?.length) {
this.source = value;
this.visibleCount = value.length;
}
}
get totalCount() {
return this.source.length;
}
get scenarioDescription() {
return (
this.scenarios.find((item) => item.id === this.activeScenario)
?.description ?? ''
);
}
get appliedOutput() {
return formatJson(this.appliedAst);
}
get eventOrigin() {
return this.eventDetail?.origin ?? '—';
}
get projectable() {
return String(this.eventDetail?.projectable ?? false);
}
get transportOutput() {
return this.eventDetail
? formatJson(createTransportEnvelope(this.eventDetail))
: 'Apply a scenario to capture an event.';
}
get validationOutput() {
return this.validation.length
? formatJson(this.validation)
: 'No diagnostics.';
}
async ngAfterViewInit() {
this.grid = this.gridRef.nativeElement;
this.grid.addEventListener('filterastchange', this.onAstChange);
this.grid.addEventListener('filterasterror', this.onAstError);
this.grid.addEventListener('afterfilterapply', this.onAfterFilter);
this.themeObserver = new MutationObserver(() => {
this.theme = isDarkTheme() ? 'darkCompact' : 'compact';
});
this.themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme', 'class'],
});
await customElements.whenDefined('revo-grid');
await this.applyScenario('nested');
}
ngOnDestroy() {
this.grid?.removeEventListener('filterastchange', this.onAstChange);
this.grid?.removeEventListener('filterasterror', this.onAstError);
this.grid?.removeEventListener('afterfilterapply', this.onAfterFilter);
this.themeObserver?.disconnect();
}
async applyScenario(id: AstScenarioId) {
if (!this.grid) return;
this.activeScenario = id;
this.validation = [];
this.validationSummary = 'No validation errors.';
const plugin = await getAdvanceFilterPlugin(this.grid);
await plugin.setFilterAst(createFilterAst(id, this.reference), {
preserveQuickFilter: this.preserveQuickSearch,
});
if (!this.preserveQuickSearch) {
this.quickSearch = '';
this.grid.quickFilter = undefined;
}
this.savedAst ??= roundTripFilterAst(plugin.getFilterAst());
await this.syncState();
}
applyQuickSearch(event: Event) {
if (!this.grid) return;
this.quickSearch = (event.target as HTMLInputElement).value;
this.grid.quickFilter = this.quickSearch
? { text: this.quickSearch, debounceMs: 0 }
: undefined;
}
setPreserveQuickSearch(event: Event) {
this.preserveQuickSearch = (event.target as HTMLInputElement).checked;
}
async restoreSaved() {
if (!this.grid || !this.savedAst) return;
await (
await getAdvanceFilterPlugin(this.grid)
).setFilterAst(roundTripFilterAst(this.savedAst));
await this.syncState();
}
async tryInvalid() {
if (!this.grid) return;
const plugin = await getAdvanceFilterPlugin(this.grid);
const beforeAst = formatJson(plugin.getFilterAst());
const beforeCount = (await this.grid.getVisibleSource()).length;
try {
await plugin.setFilterAst(invalidFilterAst as unknown as FilterAst);
} catch {
await this.syncState();
this.validationSummary =
beforeAst === formatJson(this.appliedAst) &&
beforeCount === this.visibleCount
? 'Rejected atomically; the AST and visible rows were preserved.'
: 'Rejected, but the preservation check changed unexpectedly.';
}
}
async clearAst() {
if (!this.grid) return;
this.quickSearch = '';
this.grid.quickFilter = undefined;
await (await getAdvanceFilterPlugin(this.grid)).setFilterAst(undefined);
await this.syncState();
}
private async syncState() {
if (!this.grid) return;
const plugin = await getAdvanceFilterPlugin(this.grid);
this.appliedAst = plugin.getFilterAst();
this.visibleCount = (await this.grid.getVisibleSource()).length;
}
private readonly onAstChange = (event: Event) => {
this.eventDetail = (
event as CustomEvent<FilterAstChangeEventDetail>
).detail;
void this.syncState();
};
private readonly onAstError = (event: Event) => {
this.validation = (
event as CustomEvent<FilterAstErrorEventDetail>
).detail.diagnostics;
};
private readonly onAfterFilter = () => {
void this.syncState();
};
}
import type { ColumnFilterConfig, ColumnRegular } from '@revolist/revogrid';
import {
AdvanceFilterPlugin,
type FilterAst,
type FilterAstChangeEventDetail,
type FilterExecutionContext,
} from '@revolist/revogrid-pro';
export type SupportTicket = {
id: number;
subject: string;
status: 'Open' | 'Pending' | 'Closed';
priority: number;
vip: boolean;
tags: string[];
dueDate: string;
updatedAt: string;
internalRisk: number;
};
export type AstScenarioId =
| 'nested'
| 'typed'
| 'dates'
| 'hidden'
| 'projectable';
export type AstScenario = {
id: AstScenarioId;
label: string;
description: string;
createAst: (reference: Date) => FilterAst;
};
export type FilterTransportEnvelope = {
filter: FilterAst | undefined;
context: FilterExecutionContext;
};
const DAY = 86_400_000;
function dateAt(reference: Date, offset: number) {
return new Date(reference.getTime() + offset * DAY);
}
function dateOnly(reference: Date, offset: number) {
return dateAt(reference, offset).toISOString().slice(0, 10);
}
function instant(reference: Date, offset: number, hour = 12) {
const date = dateAt(reference, offset);
date.setUTCHours(hour, 0, 0, 0);
return date.toISOString();
}
export function captureWorkbenchReference(now = new Date()) {
const reference = new Date(now);
reference.setUTCHours(12, 0, 0, 0);
return reference;
}
export function createFilterAstRows(
reference = captureWorkbenchReference(),
): SupportTicket[] {
const fixtures: Array<Omit<SupportTicket, 'id' | 'dueDate' | 'updatedAt'>> = [
{
subject: 'Login loop',
status: 'Open',
priority: 5,
vip: true,
tags: ['auth', 'urgent'],
internalRisk: 9,
},
{
subject: 'Invoice export',
status: 'Pending',
priority: 4,
vip: false,
tags: ['billing'],
internalRisk: 7,
},
{
subject: '',
status: 'Open',
priority: 2,
vip: false,
tags: [],
internalRisk: 3,
},
{
subject: 'Slow dashboard',
status: 'Closed',
priority: 3,
vip: false,
tags: ['performance'],
internalRisk: 6,
},
{
subject: 'SSO mapping',
status: 'Pending',
priority: 5,
vip: true,
tags: ['auth', 'enterprise'],
internalRisk: 10,
},
{
subject: 'Webhook retry',
status: 'Open',
priority: 4,
vip: false,
tags: ['api'],
internalRisk: 8,
},
{
subject: 'Seat count',
status: 'Closed',
priority: 1,
vip: false,
tags: ['billing'],
internalRisk: 2,
},
{
subject: 'Mobile layout',
status: 'Open',
priority: 3,
vip: false,
tags: ['ui'],
internalRisk: 5,
},
{
subject: '',
status: 'Pending',
priority: 1,
vip: true,
tags: ['urgent'],
internalRisk: 9,
},
{
subject: 'Audit log',
status: 'Closed',
priority: 5,
vip: false,
tags: ['enterprise'],
internalRisk: 7,
},
{
subject: 'CSV encoding',
status: 'Open',
priority: 2,
vip: false,
tags: ['export'],
internalRisk: 4,
},
{
subject: 'API timeout',
status: 'Pending',
priority: 3,
vip: false,
tags: ['api', 'urgent'],
internalRisk: 8,
},
];
const offsets = [0, 2, -1, -8, 7, 0, -30, 14, -2, 4, 1, -6];
return fixtures.map((ticket, index) => ({
...ticket,
id: index + 1,
dueDate: dateOnly(reference, offsets[index]),
updatedAt: instant(reference, offsets[index] - 1, 8 + (index % 8)),
}));
}
export const filterAstColumns: ColumnRegular[] = [
{ name: 'Ticket', prop: 'id', size: 80, filter: 'number' },
{ name: 'Subject', prop: 'subject', size: 190, filter: 'string' },
{ name: 'Status', prop: 'status', size: 115, filter: 'string' },
{ name: 'Priority', prop: 'priority', size: 100, filter: 'number' },
{ name: 'VIP', prop: 'vip', size: 80, filter: 'boolean' },
{
name: 'Tags',
prop: 'tags',
size: 150,
filter: 'array',
columnType: 'array',
},
{
name: 'Due date',
prop: 'dueDate',
size: 125,
filter: 'date',
columnType: 'date',
},
{
name: 'Updated',
prop: 'updatedAt',
size: 190,
filter: 'datetime',
columnType: 'datetime',
},
];
export const filterAstPlugins = [AdvanceFilterPlugin];
export const filterAstConfig: ColumnFilterConfig = {
groupedFilter: {},
customFilters: {
riskAtLeast: {
columnFilterType: 'number',
name: 'Risk at least',
func: (value, operand) => {
const minimum =
operand && typeof operand === 'object' && 'minimum' in operand
? Number((operand as { minimum: unknown }).minimum)
: Number.NaN;
return (
typeof value === 'number' &&
Number.isFinite(minimum) &&
value >= minimum
);
},
},
},
};
export const filterAstScenarios: AstScenario[] = [
{
id: 'nested',
label: 'Nested routing',
description: 'Cross-field OR with an AND branch and a negated VIP branch.',
createAst: () => ({
type: 'group',
operator: 'or',
children: [
{
type: 'group',
operator: 'and',
children: [
{
type: 'condition',
field: 'status',
operator: 'equal',
valueType: 'string',
value: 'Open',
},
{
type: 'condition',
field: 'priority',
operator: 'greaterThanOrEqual',
valueType: 'number',
value: 3,
},
],
},
{
type: 'not',
child: {
type: 'condition',
field: 'vip',
operator: 'isTrue',
valueType: 'boolean',
},
},
],
}),
},
{
id: 'typed',
label: 'Typed values',
description:
'Blank, strict Boolean, array membership, and a numeric range.',
createAst: () => ({
type: 'group',
operator: 'or',
children: [
{
type: 'condition',
field: 'subject',
operator: 'isBlank',
valueType: 'string',
},
{
type: 'condition',
field: 'vip',
operator: 'isTrue',
valueType: 'boolean',
},
{
type: 'condition',
field: 'tags',
operator: 'in',
valueType: 'array',
value: ['urgent'],
},
{
type: 'condition',
field: 'priority',
operator: 'between',
valueType: 'number',
value: [4, 5],
},
],
}),
},
{
id: 'dates',
label: 'Dates and time',
description:
'Explicit date and UTC datetime operands plus a relative period.',
createAst: (reference) => ({
type: 'group',
operator: 'or',
children: [
{
type: 'condition',
field: 'dueDate',
operator: 'dateEquals',
valueType: 'date',
value: dateOnly(reference, 0),
},
{
type: 'condition',
field: 'updatedAt',
operator: 'dateAfter',
valueType: 'datetime',
value: instant(reference, -4, 0),
},
{
type: 'condition',
field: 'dueDate',
operator: 'today',
valueType: 'date',
},
],
}),
},
{
id: 'hidden',
label: 'Hidden + custom',
description:
'A raw non-column model field evaluated by a registered structured operator.',
createAst: () => ({
type: 'condition',
field: 'internalRisk',
operator: 'riskAtLeast',
valueType: 'unknown',
value: { minimum: 7 },
}),
},
{
id: 'projectable',
label: 'Projectable',
description:
'Independent single-field clauses that can reopen in column controls.',
createAst: () => ({
type: 'group',
operator: 'and',
children: [
{
type: 'condition',
field: 'status',
operator: 'equal',
valueType: 'string',
value: 'Open',
},
{
type: 'condition',
field: 'priority',
operator: 'greaterThanOrEqual',
valueType: 'number',
value: 3,
},
],
}),
},
];
export const invalidFilterAst = {
type: 'group',
operator: 'and',
children: [],
} as const;
export function createFilterAst(id: AstScenarioId, reference: Date) {
return filterAstScenarios
.find((scenario) => scenario.id === id)!
.createAst(reference);
}
export async function getAdvanceFilterPlugin(grid: HTMLRevoGridElement) {
const plugins = await grid.getPlugins();
const plugin = plugins.find((item) => item instanceof AdvanceFilterPlugin);
if (!plugin) throw new Error('AdvanceFilterPlugin is not registered.');
return plugin as AdvanceFilterPlugin;
}
export function roundTripFilterAst(ast: FilterAst | undefined) {
return ast === undefined
? undefined
: (JSON.parse(JSON.stringify(ast)) as FilterAst);
}
export function createTransportEnvelope(
detail: FilterAstChangeEventDetail,
): FilterTransportEnvelope {
return {
filter: roundTripFilterAst(detail.filterAst),
context: JSON.parse(
JSON.stringify(detail.executionContext),
) as FilterExecutionContext,
};
}
export function formatJson(value: unknown) {
return JSON.stringify(value, null, 2) ?? 'undefined';
}
.filter-ast-workbench {
--filter-ast-border: color-mix(in srgb, currentColor 18%, transparent);
display: grid;
gap: 16px;
width: 100%;
min-width: 0;
color: var(--rv-ui-text, inherit);
}
.filter-ast-workbench__toolbar,
.filter-ast-workbench__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.filter-ast-workbench__toolbar {
padding: 12px;
border: 1px solid var(--filter-ast-border);
border-radius: 10px;
}
.filter-ast-workbench__scenario {
display: grid;
gap: 4px;
flex: 1 1 100%;
}
.filter-ast-workbench__scenario strong,
.filter-ast-workbench__panel h3 {
margin: 0;
}
.filter-ast-workbench__scenario p,
.filter-ast-workbench__hint,
.filter-ast-workbench__meta {
margin: 0;
color: var(--rv-ui-text-muted, #5f6673);
font-size: 0.875rem;
}
.filter-ast-workbench__search {
min-width: 190px;
min-height: 36px;
padding: 7px 10px;
border: 1px solid var(--filter-ast-border);
border-radius: 6px;
background: var(--rv-ui-surface, transparent);
color: inherit;
}
.filter-ast-workbench__preserve {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 36px;
font-size: 0.875rem;
}
.filter-ast-workbench__grid {
display: block;
width: 100%;
min-width: 0;
height: 360px;
}
.filter-ast-workbench__inspectors {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
min-width: 0;
}
.filter-ast-workbench__panel {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
gap: 6px;
min-width: 0;
min-height: 220px;
padding: 12px;
border: 1px solid var(--filter-ast-border);
border-radius: 10px;
}
.filter-ast-workbench__panel pre {
box-sizing: border-box;
width: 100%;
max-width: 100%;
min-width: 0;
max-height: 280px;
margin: 0;
padding: 10px;
overflow: auto;
border-radius: 6px;
background: var(--rv-ui-surface-sunken, rgba(127, 127, 127, 0.1));
font-size: 0.75rem;
line-height: 1.45;
white-space: pre;
}
@media (max-width: 900px) {
.filter-ast-workbench__inspectors {
grid-template-columns: 1fr;
}
.filter-ast-workbench__panel {
min-height: 180px;
}
}
Start with Nested routing, inspect the effective tree and transport envelope, then type a quick search and apply another preset with Preserve quick search off and on. Projectable demonstrates a tree that ordinary column controls can reopen. Try invalid AST proves that validation leaves the active tree and visible rows unchanged. Open any column filter and select Groups to edit the canonical tree visually.
Register the Pro plugin
Section titled “Register the Pro plugin”import { AdvanceFilterPlugin, type FilterAst } from '@revolist/revogrid-pro';import '@revolist/revogrid-pro/dist/revogrid-pro.css';
grid.plugins = [AdvanceFilterPlugin];grid.filter = { groupedFilter: {} }; // optional Groups launchergrid.columns = columns;grid.source = rows;
const plugin = (await grid.getPlugins()).find( (item) => item instanceof AdvanceFilterPlugin,) as AdvanceFilterPlugin;Retrieve the runtime plugin from await grid.getPlugins(). Do not attach application callbacks or custom methods to the grid element.
Build a tree
Section titled “Build a tree”A condition is the smallest tree:
const openTickets: FilterAst = { type: 'condition', field: 'status', operator: 'equal', valueType: 'string', value: 'Open',};Join children with and or or, then negate any subtree with not:
const routingRule: FilterAst = { type: 'group', operator: 'or', children: [ { type: 'group', operator: 'and', children: [ openTickets, { type: 'condition', field: 'priority', operator: 'greaterThanOrEqual', valueType: 'number', value: 3, }, ], }, { type: 'not', child: { type: 'condition', field: 'vip', operator: 'isTrue', valueType: 'boolean', }, }, ],};Groups must contain at least one child. Evaluation short-circuits: and stops on the first false child, and or stops on the first true child.
Node and value contract
Section titled “Node and value contract”| Node | Required shape | Meaning |
|---|---|---|
condition | { type, field, operator, valueType, value? } | Evaluates one field. field accepts a string or numeric ColumnProp. |
group | { type, operator: 'and' | 'or', children } | Joins one or more subtrees. |
not | { type, child } | Negates exactly one subtree. |
valueType is one of string, number, boolean, date, datetime, array, or unknown. It describes the operand representation; it does not cast source values. Validation enforces scalar/date/datetime representations directly, while array and unknown rely on the selected operator’s shape rules plus JSON safety. Values may contain only finite JSON primitives, arrays, and plain objects. Public input rejects functions, undefined operands, Date, Set, cycles, non-plain objects, and NaN/infinities.
- Date operands use
YYYY-MM-DD. - Ordinary datetime operands use UTC ISO strings ending in
Z, such as2026-09-03T08:30:00Z. - Use
unknownfor an opaque, JSON-safe structured operand. It does not disable JSON validation. - Omit
evaluationModein application-authored canonical state.selectionExclusion,dateObject,coercedNumericRange, andlocalTemporalare compatibility metadata emitted when older UI/config models need evaluator-specific behavior.
When field matches a grid column, the local evaluator uses the column’s parsed cell value. A field without a column reads the raw row property, which makes hidden model fields usable without adding hidden grid columns.
Built-in operator reference
Section titled “Built-in operator reference”Every ID below is exported through CANONICAL_FILTER_OPERATORS.
| Family | Operators | valueType and operand |
|---|---|---|
| Text and equality | equal, notEqual, beginsWith, contains, quickContains, notContains | Usually string; one required JSON value matching the declared type. Equality may use another declared scalar type. |
| Ordered and numeric | greaterThan, greaterThanOrEqual, lessThan, lessThanOrEqual | Usually number; one required finite number. |
| Range | between | Usually number; a required two-item array. Numeric row values are strict in canonical mode. |
| Membership | in, notIn | array; a required array. An empty array is structurally valid. |
| Blank, Boolean, array | isBlank, isNotBlank, isTrue, isFalse, isEmptyArray, isNotEmptyArray | No operand. Keep the field’s semantic valueType, but omit value. |
| Explicit date/datetime | dateEquals, dateBefore, dateAfter, dateOnOrBefore, dateOnOrAfter, dateNotEqual | date or datetime; one required date/datetime string. |
| Date range | dateBetween | date or datetime; a required two-item array of valid date/datetime strings. |
| Relative day/window | today, yesterday, last7Days, next30Days | date or datetime; omit value. |
| Relative week/month | thisWeek, lastWeek, nextWeek, thisMonth, lastMonth | date or datetime; omit value. |
| Relative quarter/year | thisQuarter, nextQuarter, previousQuarter, thisYear, nextYear, previousYear | date or datetime; omit value. |
| Fiscal quarter | thisFiscalQuarter, nextFiscalQuarter, previousFiscalQuarter | date or datetime; omit value. |
| Fiscal year | thisFiscalYear, nextFiscalYear, previousFiscalYear | date or datetime; omit value. |
Important operator boundaries:
- Valueless operators must omit
value. Evenvalue: ''is rejected. - Canonical
in/notInuse strictObject.ismembership; an array cell matches when any member matches.selectionExclusionis a normalized, exclusion-oriented compatibility mode and is not the same contract as hand-authored canonicalnotIn. - Numeric
betweenaccepts only numeric row values in canonical mode. Blank-like values and numeric strings are not numbers. Compatibility state may carrycoercedNumericRangeto preserve older slider behavior. quickContainsis the canonical form of global quick search and retains quick-filter string coercion. Use ordinarycontainsfor Core text-filter semantics.- Relative operators use one
nowcaptured for the whole apply operation plus the resolved IANA timezone and fiscal policy. See Date Filter and Datetime Filter for temporal policy. - A custom or structured operator ID is valid only when the resolved plugin configuration registers its evaluator. Its operand must still be JSON-safe.
Apply, read, initialize, and clear
Section titled “Apply, read, initialize, and clear”await plugin.setFilterAst(routingRule);const effective = plugin.getFilterAst(); // defensive cloneawait plugin.setFilterAst(undefined); // clear canonical statesetFilterAst() validates first and replaces the tree atomically. getFilterAst() returns a defensive clone of the effective tree, including an applied quick-filter branch. Mutating either the input object after application or a returned object does not update grid state; construct a replacement and call setFilterAst() again.
For initial state, assign the AST through grid.filter:
grid.filter = { filterAst: routingRule, multiFilterItems: previousColumnState, collection: legacyCollection,};When filterAst, multiFilterItems, and collection coexist, filterAst wins.
Projectable and canonical-only trees
Section titled “Projectable and canonical-only trees”projectable means “can be represented as MultiFilterItem column controls,” not “valid.” A root and with independent single-field clauses can usually project:
{ "type": "group", "operator": "and", "children": [ { "type": "condition", "field": "status", "operator": "equal", "valueType": "string", "value": "Open" }, { "type": "condition", "field": "priority", "operator": "greaterThan", "valueType": "number", "value": 2 } ]}A cross-field or, not, or unsupported shape remains canonical-only:
{ "type": "group", "operator": "or", "children": [ { "type": "condition", "field": "status", "operator": "equal", "valueType": "string", "value": "Open" }, { "type": "not", "child": { "type": "condition", "field": "vip", "operator": "isTrue", "valueType": "boolean" } } ]}Both trees are locally executable and transport-safe. Only the first can round-trip through ordinary independent column controls.
Enable the visual grouped editor
Section titled “Enable the visual grouped editor”grid.filter = { groupedFilter: {} };groupedFilter opts the filter popup into a Groups launcher. The editor supports nested groups, NOT, rule reordering, rule/text mirrors, validation, preview counts, Apply, Cancel, and Reset. Edits are transactional: Apply replaces the active tree, Cancel leaves it untouched, and Reset clears the draft until applied.
Local preview counts stop at 25,000 eligible rows. For partial or remote data, never present the loaded page as the full dataset:
grid.filter = { groupedFilter: { remote: true, preview: async ({ ast, signal }) => { const response = await fetch('/tickets/filter-preview', { method: 'POST', signal, headers: { 'content-type': 'application/json' }, body: JSON.stringify({ filter: ast }), }); return response.json() as Promise<{ matching: number; total: number }>; }, },};The callback receives an AbortSignal; abort superseded requests and let abort errors propagate normally. remote: true without a callback displays a remote preview label rather than a misleading local total.
Save, restore, and update by replacement
Section titled “Save, restore, and update by replacement”const saved = JSON.stringify(plugin.getFilterAst());localStorage.setItem('ticket-filter', saved);
try { const json = localStorage.getItem('ticket-filter'); await plugin.setFilterAst(json ? JSON.parse(json) : undefined);} catch (error) { console.error('The saved filter is invalid.', error);}To update one part, clone/build the next tree and reapply it:
const current = plugin.getFilterAst();if (current?.type === 'group') { await plugin.setFilterAst({ ...current, children: [...current.children, anotherCondition], });}Do not mutate applied or returned objects. Compatibility conversion helpers preserve today’s existing filter behavior, but they are not a guarantee that every future Pro operator will losslessly round-trip through an older saved-view schema. Persist the canonical tree you actually apply.
Compose quick search
Section titled “Compose quick search”grid.quickFilter = { text: 'timeout', debounceMs: 0 };await plugin.setFilterAst(routingRule);// Default: clears the current or pending quick filter.
grid.quickFilter = { text: 'timeout', debounceMs: 0 };await plugin.setFilterAst(routingRule, { preserveQuickFilter: true });// Effective tree is routingRule AND the generated quickContains branch.getFilterAst() returns that effective composition. Preserve mode uses the latest applied or pending grid.quickFilter value.
Events and remote ownership
Section titled “Events and remote ownership”filterastchange fires immediately before the existing cancelable beforefilterapply event. Its detail contains { filterAst, executionContext, origin, projectable }; origin is config, api, ui, quickFilter, or clear.
import type { FilterAstChangeEventDetail, FilterExecutionContext,} from '@revolist/revogrid-pro';
type FilterRequest = { filter: FilterAstChangeEventDetail['filterAst']; context: FilterExecutionContext;};let request: FilterRequest | undefined;
grid.addEventListener('filterastchange', (event) => { const { filterAst, executionContext, origin, projectable } = event.detail; request = { filter: filterAst, context: executionContext }; console.log({ origin, projectable, request });});
grid.addEventListener('beforefilterapply', (event) => { // This event carries matching filterAst/executionContext semantic inputs. // Cancel when the remote datasource owns filtering and row replacement. event.preventDefault(); void fetch('/tickets/search', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(request), });});executionContext contains one ISO now, an IANA timeZone, and JSON-safe resolved blank semantics per column. A non-serializable custom isBlank callback appears only as customEvaluator: true; the backend must implement an equivalent policy. RevoGrid intentionally does not compile AST to SQL, REST, or GraphQL—that translation belongs to the application/backend boundary.
Atomic validation and diagnostics
Section titled “Atomic validation and diagnostics”Invalid input rejects without partially updating rows or state:
grid.addEventListener('filterasterror', (event) => { const { attemptedAst, diagnostics, origin } = event.detail; console.table(diagnostics); // [{ path, code, message }] reportInvalidSavedView({ attemptedAst, diagnostics, origin });});
try { await plugin.setFilterAst({ type: 'group', operator: 'and', children: [], } as FilterAst);} catch { // Previous AST and visible rows are still active.}Paths use JSONPath-like locations such as $.children[0].value.
| Code | Cause |
|---|---|
invalid-node | A node is not an object. |
max-depth | Nesting exceeds 100 levels. |
cycle | The AST node graph contains a cycle. |
invalid-group-operator | A group operator is not and or or. |
empty-group | A group has no children. |
missing-child | A not node has no child. |
invalid-discriminator | type is not condition, group, or not. |
invalid-field | field is not a string or number. |
invalid-operator | operator is missing or empty. |
unknown-operator | No built-in or registered evaluator matches the ID. |
invalid-value-type | valueType is not supported. |
unexpected-value | A valueless operator received value. |
missing-value | A value-taking operator omitted value. |
invalid-array-value | A range is not a two-item array, or membership is not an array. |
value-type-mismatch | The operand does not match its declared type. |
non-finite-number | A number is NaN or infinite. |
non-json-value | A value contains a function, undefined, or another non-JSON value. |
value-cycle | An operand array/object contains a cycle. |
non-plain-object | An operand is a class instance, Date, Set, or other non-plain object. |
Registered structured filter types may also return invalid-structured-value when their own operand validator rejects a value.
Parsed fields, blanks, dates, and custom operators
Section titled “Parsed fields, blanks, dates, and custom operators”Parsed columns evaluate their parsed cell value; hidden/raw model fields evaluate the raw property. Blank operators additionally preserve Core’s source identity, property-presence check, and resolved grid/column blank policy. If local policy uses blankSemantics.isBlank, remote code sees customEvaluator: true and must reproduce it.
Relative time uses the operation’s captured clock rather than calling new Date() for every row. Configure timezones, week starts, and fiscal periods through the Date Filter and Datetime Filter settings.
Register an evaluator before applying its canonical ID:
grid.filter = { customFilters: { riskAtLeast: { columnFilterType: 'number', name: 'Risk at least', func: (value, operand) => { const minimum = Number((operand as { minimum: number }).minimum); return typeof value === 'number' && value >= minimum; }, }, },};
await plugin.setFilterAst({ type: 'condition', field: 'internalRisk', // not a displayed column operator: 'riskAtLeast', valueType: 'unknown', value: { minimum: 7 },});Custom predicates run synchronously in the filtering hot path. Keep them pure: avoid DOM access, requests, mutation, and avoidable allocation. Group ordering matters for short-circuit performance, so put cheap/selective clauses early when practical. A remote or virtual datasource must evaluate the canonical request against the whole backend dataset—not only the page currently loaded in the browser.
Common mistakes
Section titled “Common mistakes”- Using
FilterAstfor a simple column filter thatmultiFilterItemsalready represents. - Supplying
value: ''to a valueless operator instead of omittingvalue. - Expecting
valueTypeto cast source data. - Sending
Date,Set,undefined, non-finite numbers, class instances, or cyclic objects. - Treating
projectable: falseas invalid instead of canonical-only. - Mutating the original or returned AST instead of applying a replacement.
- Forgetting that
setFilterAst()clears quick search unless preservation is requested. - Evaluating only the currently loaded page for a remote dataset.
- Translating relative dates remotely without the event’s exact execution context.
Continue with What Is a Filter AST? for the conceptual and business overview, Filtering Quick Start for column-state basics, Advanced Filtering Overview for the wider Pro filtering surface, Filter State and Presets for view persistence, Filter Events for lifecycle integration, and Custom Filter Operators for custom UI and evaluator patterns.