Column Filters Implementation

Type-aware, per-column filtering at the DataManager layer. A structured, source-agnostic filter model is applied client-side by StaticDataManager and serialized to server-side OData $filter by ODataDataManager. Composes with free-text search (03-data-manager-implementation.md §11) and the raw base $filter.

This document is the authoritative API/behavior reference for the shipped feature.

The filter model

A plain object keyed by column key (JSON-serializable). Columns are joined by AND; within a column the (≤2) conditions join by the per-column combinator.

{
  country: { type: 'string', combinator: 'and', conditions: [ { operator: 'equals', value: 'Italy' } ] },
  salary:  { type: 'number', conditions: [ { operator: 'between', value: 30000, valueTo: 50000 } ] },
  hired:   { type: 'date',   combinator: 'or',
             conditions: [ { operator: 'before', value: '2020-01-01' },
                           { operator: 'after',  value: '2024-01-01' } ] }
}

Operator catalog (by type)

Type Operators
string equals notEquals contains notContains startsWith endsWith isEmpty isNotEmpty in
number / uid equals notEquals greaterThan greaterThanOrEqual lessThan lessThanOrEqual between notBetween isEmpty isNotEmpty in
uuid equals notEquals isEmpty isNotEmpty in
date / datetime / time on notOn before after onOrBefore onOrAfter between isEmpty isNotEmpty
boolean isTrue isFalse isEmpty isNotEmpty

uid vs uuid. A uid column holds a numeric unique identifier, so for filtering it behaves exactly like number (number operators, a numeric <input>, Number() coercion, and a bare OData literal — Id eq 44). A uuid column is an Edm.Guid: equality / in / null checks only (substring and ordering operators don't apply to a GUID), and an unquoted OData literal — Id eq 0f9e1c2a-1b2c-3d4e-5f60-718293a4b5c6, never Id eq '0f9e…'. Any type not listed (e.g. checkbox) falls back to string.

Operator arity (number of value inputs): unary (isEmpty/isNotEmpty/isTrue/ isFalse) = 0; range (between/notBetween) = 2; in = a list; everything else = 1. The catalog, arity, and validation live in the shared, pure src/vanilla-grid/filter-model.js (window.VanillaGridFilterModel):

The filter panel renders one value input per arity, except in which renders a dynamic value-list editor: one typed input per value (so a number/date column gets native pickers and a localized decimal like 100,20 is one unambiguous value — no separator to parse), each with a remove (−) control, plus an "add value" (+) button. Enter inside a value row adds another row rather than applying. Labels come from messages.filterListAdd / messages.filterListRemove.

Operator-change focus. When the user changes the operator by clicking a new option, the panel drops the caret into the first freshly-rendered value input (the common next step is entering the value, so this saves a click). Unary operators (isEmpty/isNotEmpty/isTrue/isFalse) render no input, so focus stays on the operator dropdown. Changing the operator by keyboard deliberately does not move focus — a native <select> fires change on every arrow step, so moving focus then would break keyboard operator navigation; the focus move is gated on a pointer-initiated change (header-menu.feature.js). The caret is placed at the end of any value carried over from the previous operator (not selected), so tweaking only the operator never risks clobbering an intact value.

DataManager contract

Four methods (no-ops on the base DataManager):

dm.setColumnFilters(model);          // replace whole model
dm.getColumnFilters();               // → normalized clone
dm.setColumnFilter(key, columnFilter); // set/clear one column (null clears)
dm.clearColumnFilters();             // clear all

Setters store the normalized model and fire _fireConfigChanged('setColumnFilters') — so with setAutoReloadOnConfigChange(true) the grid reloads automatically; otherwise call reloadDataManager(). Setters are no-ops when the normalized model is unchanged (avoids a redundant reload). The active model persists across reset()/reloadDataManager() (like the search term and $filter).

StaticDataManager (client-side)

Compiles the model into a single (row) => boolean predicate (memoized), applied after the free-text search inside _getFilteredRowsAsync() — so paging and count operate on the filtered set. Options:

Coercion: numbers via Number() (non-finite → no match), dates via Date.parse / epoch (unparseable → no match), booleans via the grid's tri-state coercion. on/notOn compare by calendar day.

ODataDataManager (server-side)

Serializes the model to a $filter fragment and AND-merges it with the base $filter and the 'filter'-mode search disjunction via _composeEffectiveFilter():

$filter = (base) and (columnFilters) and (searchDisjunction)

_buildUrl() and _refreshTotalRowCount() both use the composite, so paged fetches and the total count reflect the filters. Literal formatting by type:

Type Literal Example
string '…', ''' 'O''Brien'
number / uid bare 42
uuid unquoted Edm.Guid 0f9e1c2a-1b2c-3d4e-5f60-718293a4b5c6
boolean true/false true
date unquoted 2024-01-31
datetime unquoted ISO 2024-01-31T09:30:00.000Z
time unquoted 09:30:00

Operator mapping highlights: containscontains(f,'v'), startsWithstartswith, endsWithendswith, between(f ge a and f le b), isEmpty (string)→(f eq null or f eq ''). For a datetime column, on/notOn expand to a half-open day window (f ge dayStart and f lt nextDay).

in ("is any of"): the OData in operator is 4.01+, and many endpoints reject it (OData V2/V3, OData 4.0 services, and the Northwind demo service's older parser — "Syntax error … in 'f in (…)'"). So by default in is serialized as an eq/or disjunction — (f eq a or f eq b or …) — which is valid on every OData version. Set the useInOperator: true option against a known 4.01+ service to emit the compact f in (a,b,…) form instead.

Options: caseInsensitive, columnFilters, useInOperator (default false).

<vn-grid> element API

await grid.setColumnFilters(model);          // returns rows (or [] under auto-reload)
await grid.setColumnFilter('salary', { type: 'number', conditions: [ { operator: 'greaterThan', value: 1000 } ] });
await grid.clearColumnFilters();             // filters only
await grid.clearColumnFiltersAndSorting();   // filters + sort, keeps column layout

clearColumnFiltersAndSorting() is the middle-ground reset: it clears the active filter model and all sorting (including their persisted values) with a single coordinated reload, while preserving persisted column layout (widths, order, hidden, frozen). Contrast clearColumnFilters() (filters only) and clearPersistedSettings() (everything, layout included).

The element is the single authority on column capabilities (managers hold no column defs). Before delegating it:

  1. drops non-filterable columns (filtering.enabled === false grid-wide, or column.filterable === false) with a warning;
  2. enriches each kept entry with fields (from filterFields, defaulting to key; multiple fields are OR-ed together — fan-out), type (from filterType/type) and operators (from filterOperators, when set);
  3. forwards any filterValueGetters to managers that support them.

Header filter icon

Every filterable column header shows a small funnel icon at its far right (.vn-grid-filter-icon). Clicking it opens the same filter panel as the context-menu "Filter…" item (anchored under the icon). When the column has an active filter the icon switches from an outline funnel to a filled one (.vn-grid-filter-icon-active). The fill state is kept in sync by the element: after every setColumnFilter/setColumnFilters/clearColumnFilters it calls grid.setFilteredColumnKeys(activeKeys). The icon is hidden for non-filterable columns and when filtering is disabled grid-wide.

The funnel button carries no SVG markup — the glyph is a CSS-owned masked ::before whose shape comes from the --vn-grid-filter-icon / --vn-grid-filter-icon-active theme tokens (see 15-themes-implementation.md). This lets each theme ship a different funnel without any JS change; the active class just swaps the mask to the filled variant.

The funnel is absolutely positioned (right: 13px) and therefore out of flow, so the header label reserves right-side space for it to keep the column's text and the inline sort indicator clear of the funnel. A :has() rule applies padding-right to .vn-grid-header-label only on headers that actually render a funnel — without it, a sorted column's sort arrow would collide with and hide the funnel.

Per-column opt-out & targeting

Mirrors sortable:

<vn-grid-column field="avatar" filterable="false"></vn-grid-column>
<!-- filter a different field than is displayed (custom renderer) -->
<vn-grid-column field="EmployeeID" filter-fields="Employee.LastName" filter-type="string"></vn-grid-column>
<!-- composite column: filter several backing fields, OR-ed together (fan-out) -->
<vn-grid-column field="Employee.Location" filter-fields="Employee.City,Employee.Country" filter-type="string"></vn-grid-column>

filter-fields accepts a comma-separated list of dotted field paths. With a single field this is the plain remap case; with several it covers composite / derived columns (e.g. a "City, Country" cell) that have no single backing scalar. On the OData manager each path is converted ./.

Fan-out semantics (multi-field columns)

Each condition is evaluated against every backing field, and the per-field results are combined by operator polarity:

The per-condition results then join by the column's own combinator (and/or). Single-field columns collapse to one clause, so polarity is a no-op for them.

Operator reduction (multi-field columns)

Only operators that stay accurate across several fields are offered when a column has 2+ filter-fields: contains, notContains, isEmpty, isNotEmpty. Whole-value / anchored / ordering operators (equals, startsWith, endsWith, in, >, between, dates) compare a single part rather than the assembled value, so they're filtered out — both in the filter UI (operatorsForColumn(type, fieldCount)) and in normalizeFilterModel (a stray unsafe operator on a multi-field column is dropped, leaving the column inactive). A multi-field numeric/date column is therefore left with only the presence checks — multi-field fan-out is really a string-search feature. Single-field columns keep the full type catalog. To filter a composite by its assembled value (e.g. equals "Seattle, USA"), use a filterValueGetter (static) or a server-side concat() expression instead of fan-out.

{ key: 'rating', type: 'number', renderCell, /* filters on raw number — no config */ }
{ key: 'orderTotal', filterable: false }   // computed column, no server field

Grid-global toggle: initializeGrid({ filtering: { enabled: false } }).

Per-column operator allow-list (filterOperators)

A column can restrict its filter panel to a subset of its type's operator catalog — for server-side sources whose backend can't honor every operator the client type implies (e.g. a GraphQL API whose genre/format args are exact-match tokens with no substring filter):

<vn-grid-column field="format" filter-operators="in,equals" default-filter-operator="in"></vn-grid-column>
{ key: 'format', filterOperators: ['in', 'equals'], defaultFilterOperator: 'in' }

Semantics (purely additive — absent ⇒ today's behavior, full type catalog):

defaultFilterOperator works on its own — it does not require a filterOperators allow-list. Use it to change only the operator a column's panel opens on. For example, a string column whose type default is Contains but which is more naturally an exact match:

<vn-grid-column key="status" title="Status" filter-type="string"
                default-filter-operator="equals"></vn-grid-column>
{ key: 'status', filterType: 'string', defaultFilterOperator: 'equals' }

The full operator catalog is still offered; only the initially-selected operator changes (and only when the column has no active filter — a persisted filter's operator still wins on reopen).

Boolean column type

type: 'boolean' is a first-class type: strict tri-state (true/false/empty), a default read-only checkbox renderer (checked / unchecked / disabled-unchecked for empty), a text fallback in _formatByType for export (//``), a false < true sort comparator, and the boolean filter operators above. Raw values are coerced via VanillaGrid._coerceBoolean (strict by default; opt into 1/0,'yes'/'no',… with booleanCoerce: 'loose'). A custom renderCell overrides the default checkbox.

Files

File Role
filter-model.js catalog + normalize/validate/clone (pure, shared)
data-managers/data-manager.js base no-op contract
data-managers/static-data-manager.js predicate engine
data-managers/odata-data-manager.js $filter serialization + composite
vanilla-grid-element.js delegation + filterable enforcement + attributes
vanilla-grid.js boolean type (_coerceBoolean, _formatByType), filtering.enabled
features/rendering.feature.js boolean checkbox renderer
features/sorting-comparator.feature.js boolean comparator
features/header-menu.feature.js header-menu filter UI