Query Builder
ProNested AND/OR condition builder — add/remove rules and groups, typed operators per field (string / number / date / enum / boolean), a serializable JSON expression out and import of a saved one back in, and a debounced queryChange output that pairs with a data-table's server-side filtering.
Also belongs under Forms & Inputs — it is catalogued in Data Display and stays there.
Complexity tier: Application The interactive bar, plus a stated data-volume ceiling, a persistence contract, a bundle budget and an accessible parity surface.
import { SvQueryBuilderComponent } from '@styloviz-pro/query-builder';Demo & usage
Basic usage
ProUsage example — open the gallery for the full interactive demo.
An interactive demo for this component lives in the gallery.
Open live demoMore examples
Nested AND/OR groups
ProPairs with a data table's server-side filtering
ProThe headless engine, outside Angular
ProTranslating to SQL without opening an injection
ProWhat a date rule means, in which timezone
ProWhen to use
- Letting a user build an arbitrary AND/OR filter — support tooling, saved searches, audience/segment builders.
- You need the filter itself to be saved, shared or replayed (`svSerializeQuery()` / `svImportQuery()`), not just applied once — the JSON carries a schema version, so a query saved last release is migrated or refused with a reason rather than read as the current shape.
- For a single row of per-column filters ANDed together, a data-table's own column filters or `sv-filter-panel`'s facets are lighter weight — `SvDataQuery.filters` is deliberately flat, which is why this ships its own richer expression type.
Async states
- loading
- empty
- error
- retry
Saved state
- Stored kinds
- sv-query-builder/query
What you will find under your own storage key. You choose the key; the blob names its own
kindand schemaversion, so a component never reads state another one wrote.- Reading it back
- Restore through the component's own
restore…helper rather than casting the parsed JSON. A blob from an older release is migrated where it can be and refused with a reason where it cannot — so an upgrade never silently applies last month's shape to this month's fields.
Inputs
| Name | Type | Default | Description |
|---|---|---|---|
| title | string | '' | Heading shown above the builder. |
| fieldsrequired | readonly SvQueryField[] | — | Every field a rule can filter on. |
| expression | SvQueryExpression (two-way) | emptyExpression() | The expression tree — two-way, so a host can persist and restore a saved query. |
| maxDepth | number | 3 | How many group levels deep nesting is allowed to go. Root counts as depth 0. |
| loading | boolean | false | True while `[fields]` is being fetched from a schema endpoint. A query builder with no fields yet cannot render anything meaningful, so this follows the same async contract as a data-bearing component even though the *expression* itself is always local, synchronous state. |
| error | SvDataError | string | null | null | Failure loading `[fields]`. |
| emptyMessage | string | 'No filterable fields are available yet… | Empty-state message when `[fields]` is `[]` and nothing is loading or failed. |
| timeZone | string | '' | The IANA timezone a date rule's calendar days are reckoned in, named under the preview so the user can see it. A date rule says "before 8 Sep 2026", and a row in the host's table is an instant — which day an instant near midnight falls on depends on this. Leave it unset for the browser's own zone, which is right when the user is reading their own dashboard; set it to the zone the *data* is reckoned in when a report has to agree with a server, since that is the one thing this component cannot work out. `svDateRuleWindow()` takes the same zone and does the conversion. |
| debounceMs | number | 300 | Delay before `(queryChange)` fires after an edit, in ms. `0` emits immediately. Every edit updates `[(expression)]` (hence the UI) synchronously — this only smooths the *emission*, exactly like `sv-filter-panel`'s `debounceMs`, so a host wiring a refetch to `(queryChange)` doesn't fire once per keystroke while a value is typed. |
Outputs
| Event | Payload type | Description |
|---|---|---|
| queryChange | SvQueryExpression | The expression changed — debounced. Wire this to a refetch. |
| retry | void | The host should re-run the failed `[fields]` load. |
| expressionChange | SvQueryExpression | Emits the new `expression` whenever it changes — the output half of `[(expression)]`. |
Wire it to your API
This component is headless about transport: it renders what you give it and reports what the user asks for. Nothing below requires @styloviz/data — it just saves you writing the debounce, the request guard and the retry policy by hand.
Template
ProBound to the names this component actually exposes.
Host class
ProOne controller handles debounce, request-guarding, cancellation and retry.
- `queryChange` carries an `SvDataQuery` — page, page size, sorts, filters, search and cursor in one object. One handler covers every way the user can change what they are asking for.
- `debounceMs` (default 300) collapses a burst of typing into one request. Set it to 0 only when the query is driven by clicks.
- Normalize failures with `toSvDataError()` from `@styloviz/core`. It derives `retryable` from the status, so a 404 does not get a "Try again" button that can only fail again.
Full walkthrough: Connecting to a backend.
Customization & theming
- Five field types (string / number / date / enum / boolean), each with its own operator set and value editor (single, range, multi-select, or none for `is true`/`is empty`).
- Unlimited rules per group; groups nest up to `maxDepth`.
- `svImportQuery()` degrades gracefully when a saved query's field schema has drifted — an unknown field clears the whole rule, a field whose saved operator no longer fits keeps the field and clears only the operator/value — and reports both in `result.report` so the user can be told. `svParseQuery()` is the forgiving form for a caller with nowhere to show a message.
- **Operator/type pairing is enforced, not just offered.** The dropdown was the only thing obeying it, so `applyQueryAction` stored whatever it was handed and "age contains 5" round-tripped through export, import and `queryChange` as a real filter. An action that would put a rule in a state no field can hold is now a no-op — and a refused action emits nothing, because a query that did not change is not a reason to refetch. `isOperatorValidFor()` and `ruleProblem()` are exported for a host validating a query it built itself.
- **A plain-language preview** above the rows: "Reads as: Plan is “Pro” and Signed up after 1 Jun 2026." Enum values read by their label, dates as the day the user picked, a nested group bracketed only when it holds more than one condition, and an unfinished rule named rather than skipped — a preview that reads as a finished sentence while the query cannot run is a worse trap than none.
Best practices
- Do: Wire `(queryChange)` (already debounced) to a refetch, not `[(expression)]` directly — the model updates on every keystroke so the UI stays responsive, but a refetch should not.
- Do: Set `maxDepth` to whatever nesting your backend can actually translate — three levels covers nearly every real filter without becoming unreadable.
- Tip: The built-in Export/Import panel round-trips through the same `svSerializeQuery()` / `svImportQuery()` the package exports, so "save this search" and "load a saved search" need no extra code — and it shows what an import had to change, rather than accepting a query that now means something else.
- Do: **Translate through `svQueryToPredicate()`, and bind its `params`.** This component escapes nothing and produces no query language — that is the boundary it does not cross, and your translation is the other side of it, where a value the user typed becomes code. The predicate hands over your own field ids and a closed operator set (safe to map through your own lookup tables) separately from `params`, which are the user's raw input and must go into a placeholder — `?`, `$1`, a driver's parameter object. Concatenating `params` into SQL is an injection in a component whose whole job is collecting strings from a user.
- Do: Set `timeZone` to the zone your **data** is reckoned in whenever that is not the reader's own — a date rule is a calendar day and a stored row is an instant, so which day a row near midnight falls on depends on it. `svDateRuleWindow()` does the conversion with the same zone, and the preview names the zone in use so the user can see which calendar they are filtering in.
- Don't: Don't fetch on a query `svQueryToPredicate()` returned `null` for. An unfinished rule would translate to a comparison against the empty string — a filter that runs, matches nothing, and looks like an empty table rather than a mistake.
Accessibility
- Every field / operator / value control is a native `<select>`/`<input>`, so keyboard and screen-reader support come for free.
- Add/remove rule and group buttons are labelled buttons, not icon-only.
- Loading, error and empty states for `[fields]` follow the kit-wide async contract (`data-sv-state`) — the expression itself is always local, synchronous state.
- Nested AND/OR is the part of this component a non-technical user cannot check their own work on — the rails and indentation say where a group begins and nothing said what the whole query would *do*. The preview says it, in one sentence.
- An unfinished condition says what it needs **on the row**, and the row's field control is `aria-describedby` it, so the reason is read out with the control. The toolbar's "some conditions are incomplete" is true of the query and useless about the rule: in three nested groups it left the user checking every row by eye.
- The preview is deliberately not a live region — it restates the whole query, so `aria-live` would re-read every condition on each keystroke of a value.
Feedback on Query Builder
Spotted a gap in this page, or something that didn't work as described? Comments are GitHub Discussions in the public styloviz/community repo, so posting needs a GitHub account.