Skip to content

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 multiFilterItems for ordinary per-column filtering and existing column controls. Use FilterAst for cross-column OR, nested groups, NOT, canonical saved views, or a transport-safe remote query. Canonical execution requires AdvanceFilterPlugin; Core FilterPlugin remains the right choice for ordinary Core filtering.

Source code
TypeScriptts
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();
  };
}
Vuevue
<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>
Reacttsx
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;
Angularts
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();
  };
}
Shared setupts
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';
}
Shared stylesscss
.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.

import { AdvanceFilterPlugin, type FilterAst } from '@revolist/revogrid-pro';
import '@revolist/revogrid-pro/dist/revogrid-pro.css';
grid.plugins = [AdvanceFilterPlugin];
grid.filter = { groupedFilter: {} }; // optional Groups launcher
grid.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.

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.

NodeRequired shapeMeaning
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 as 2026-09-03T08:30:00Z.
  • Use unknown for an opaque, JSON-safe structured operand. It does not disable JSON validation.
  • Omit evaluationMode in application-authored canonical state. selectionExclusion, dateObject, coercedNumericRange, and localTemporal are 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.

Every ID below is exported through CANONICAL_FILTER_OPERATORS.

FamilyOperatorsvalueType and operand
Text and equalityequal, notEqual, beginsWith, contains, quickContains, notContainsUsually string; one required JSON value matching the declared type. Equality may use another declared scalar type.
Ordered and numericgreaterThan, greaterThanOrEqual, lessThan, lessThanOrEqualUsually number; one required finite number.
RangebetweenUsually number; a required two-item array. Numeric row values are strict in canonical mode.
Membershipin, notInarray; a required array. An empty array is structurally valid.
Blank, Boolean, arrayisBlank, isNotBlank, isTrue, isFalse, isEmptyArray, isNotEmptyArrayNo operand. Keep the field’s semantic valueType, but omit value.
Explicit date/datetimedateEquals, dateBefore, dateAfter, dateOnOrBefore, dateOnOrAfter, dateNotEqualdate or datetime; one required date/datetime string.
Date rangedateBetweendate or datetime; a required two-item array of valid date/datetime strings.
Relative day/windowtoday, yesterday, last7Days, next30Daysdate or datetime; omit value.
Relative week/monththisWeek, lastWeek, nextWeek, thisMonth, lastMonthdate or datetime; omit value.
Relative quarter/yearthisQuarter, nextQuarter, previousQuarter, thisYear, nextYear, previousYeardate or datetime; omit value.
Fiscal quarterthisFiscalQuarter, nextFiscalQuarter, previousFiscalQuarterdate or datetime; omit value.
Fiscal yearthisFiscalYear, nextFiscalYear, previousFiscalYeardate or datetime; omit value.

Important operator boundaries:

  • Valueless operators must omit value. Even value: '' is rejected.
  • Canonical in/notIn use strict Object.is membership; an array cell matches when any member matches. selectionExclusion is a normalized, exclusion-oriented compatibility mode and is not the same contract as hand-authored canonical notIn.
  • Numeric between accepts only numeric row values in canonical mode. Blank-like values and numeric strings are not numbers. Compatibility state may carry coercedNumericRange to preserve older slider behavior.
  • quickContains is the canonical form of global quick search and retains quick-filter string coercion. Use ordinary contains for Core text-filter semantics.
  • Relative operators use one now captured 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.
await plugin.setFilterAst(routingRule);
const effective = plugin.getFilterAst(); // defensive clone
await plugin.setFilterAst(undefined); // clear canonical state

setFilterAst() 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 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.

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.

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.

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.

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.

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.

CodeCause
invalid-nodeA node is not an object.
max-depthNesting exceeds 100 levels.
cycleThe AST node graph contains a cycle.
invalid-group-operatorA group operator is not and or or.
empty-groupA group has no children.
missing-childA not node has no child.
invalid-discriminatortype is not condition, group, or not.
invalid-fieldfield is not a string or number.
invalid-operatoroperator is missing or empty.
unknown-operatorNo built-in or registered evaluator matches the ID.
invalid-value-typevalueType is not supported.
unexpected-valueA valueless operator received value.
missing-valueA value-taking operator omitted value.
invalid-array-valueA range is not a two-item array, or membership is not an array.
value-type-mismatchThe operand does not match its declared type.
non-finite-numberA number is NaN or infinite.
non-json-valueA value contains a function, undefined, or another non-JSON value.
value-cycleAn operand array/object contains a cycle.
non-plain-objectAn 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.

  • Using FilterAst for a simple column filter that multiFilterItems already represents.
  • Supplying value: '' to a valueless operator instead of omitting value.
  • Expecting valueType to cast source data.
  • Sending Date, Set, undefined, non-finite numbers, class instances, or cyclic objects.
  • Treating projectable: false as 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.