<bmx-query-builder>
A visual query builder: rules of a field, a condition and a value, in groups joined by AND or OR (with NOT on a group), nested as deep as allowed.
22 properties · 1 events · 14 methods · 9 parts
Example
The same query, filtering records in the page
toPredicate() gives a function for Array.prototype.filter: no server, no query language.
| Name | Country | Age | Spent | Limit | Plan | Joined | Tags |
|---|
Read-only, as sentences
With readonly, the same query reads as text: for a summary beside a saved report.
In a form
With a name, the query goes into the form as JSON. required asks for at least one finished rule, and AND is the only join offered here.
Show markup
<bmx-query-builder id="ex-qb" preview="sql mongo odata jsonlogic json text" sql-dialect="postgres" label="Customer filter"></bmx-query-builder>
<div class="row" style="margin-block-start: 0.75rem; gap: 0.75rem; flex-wrap: wrap; align-items: center">
<label>SQL dialect
<select id="ex-qb-dialect">
<option value="ansi">ANSI</option>
<option value="postgres" selected>PostgreSQL</option>
<option value="mysql">MySQL</option>
<option value="sqlserver">SQL Server</option>
<option value="sqlite">SQLite</option>
<option value="oracle">Oracle</option>
</select>
</label>
<label><input type="checkbox" id="ex-qb-case" checked> Ignore case</label>
<bmx-button variant="outline" tone="neutral" id="ex-qb-check">Check the query</bmx-button>
<bmx-button variant="outline" tone="neutral" id="ex-qb-clear">Clear</bmx-button>
</div>
<p class="note" id="ex-qb-out" role="status"></p>
<h3>The same query, filtering records in the page</h3>
<p class="note"><code>toPredicate()</code> gives a function for <code>Array.prototype.filter</code>: no server, no query language.</p>
<div style="max-block-size: 18rem; overflow: auto; border: 1px solid var(--bmx-border, #d5dce3); border-radius: 8px">
<table id="ex-qb-table" style="inline-size: 100%; border-collapse: collapse; font-size: 0.8125rem">
<thead>
<tr style="text-align: start; position: sticky; top: 0; background: var(--bmx-surface-sunken, #f6f8fa)">
<th style="padding: 0.375rem 0.5rem; text-align: start">Name</th>
<th style="padding: 0.375rem 0.5rem; text-align: start">Country</th>
<th style="padding: 0.375rem 0.5rem; text-align: end">Age</th>
<th style="padding: 0.375rem 0.5rem; text-align: end">Spent</th>
<th style="padding: 0.375rem 0.5rem; text-align: end">Limit</th>
<th style="padding: 0.375rem 0.5rem; text-align: start">Plan</th>
<th style="padding: 0.375rem 0.5rem; text-align: start">Joined</th>
<th style="padding: 0.375rem 0.5rem; text-align: start">Tags</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<h3>Read-only, as sentences</h3>
<p class="note">With <code>readonly</code>, the same query reads as text: for a summary beside a saved report.</p>
<bmx-query-builder id="ex-qb-read" readonly></bmx-query-builder>
<h3>In a form</h3>
<p class="note">With a <code>name</code>, the query goes into the form as JSON. <code>required</code> asks for at least one finished rule, and AND is the only join offered here.</p>
<form id="ex-qb-form" style="display: grid; gap: 0.75rem">
<bmx-query-builder id="ex-qb-alert" name="alertRule" required combinators="and" max-depth="2" label="Alert when"></bmx-query-builder>
<div class="row" style="gap: 0.5rem">
<bmx-button type="submit">Save the alert</bmx-button>
</div>
</form>
<pre class="note" id="ex-qb-form-out" style="white-space: pre-wrap" hidden></pre>
<script type="module">
await customElements.whenDefined('bmx-query-builder');
const countries = [
['UK', 'United Kingdom'],
['IE', 'Ireland'],
['FR', 'France'],
['DE', 'Germany'],
['ES', 'Spain'],
['IT', 'Italy'],
['NL', 'Netherlands'],
['SE', 'Sweden'],
['US', 'United States'],
['CA', 'Canada'],
['AU', 'Australia'],
['JP', 'Japan'],
['IN', 'India'],
['BR', 'Brazil'],
].map(([value, label]) => ({ value, label }));
const fields = [
{ name: 'name', label: 'Name', group: 'Customer', placeholder: 'Text to match' },
{ name: 'email', label: 'Email', group: 'Customer', pattern: '^[^@\\s]*@?[^@\\s]*$', patternMessage: 'Part of an email address, with no spaces.' },
{ name: 'country', label: 'Country', type: 'list', options: countries, group: 'Customer' },
{ name: 'address.city', label: 'City', group: 'Customer' },
{ name: 'age', label: 'Age', type: 'number', integer: true, min: 16, max: 120, group: 'Customer' },
{ name: 'plan', label: 'Plan', type: 'list', options: [{ value: 'free', label: 'Free' }, { value: 'team', label: 'Team' }, { value: 'business', label: 'Business' }], group: 'Account' },
{ name: 'spent', label: 'Spent (£)', type: 'number', compareTo: true, group: 'Account', description: 'Lifetime spend' },
{ name: 'limit', label: 'Credit limit (£)', type: 'number', group: 'Account' },
{ name: 'joined', label: 'Joined', type: 'date', group: 'Account' },
{ name: 'lastOrder', label: 'Last order', type: 'datetime', group: 'Account' },
{ name: 'member', label: 'Newsletter', type: 'boolean', group: 'Account' },
{ name: 'tags', label: 'Tags', type: 'list', multiple: true, options: ['vip', 'trade', 'new', 'at-risk', 'reseller'].map(value => ({ value })), group: 'Account' },
];
const today = new Date();
const day = n => {
const d = new Date(today);
d.setDate(d.getDate() - n);
return d.toISOString().slice(0, 10);
};
const first = ['Ann', 'Ben', 'Chloe', 'Dev', 'Ella', 'Finn', 'Gita', 'Hugo', 'Isla', 'Jon', 'Kemi', 'Liam', 'Maya', 'Noah', 'Orla', 'Pavel'];
const last = ['Smith', 'Okafor', 'Martin', 'Patel', 'Rossi', 'Novak', 'Kim', 'Dubois', 'Garcia', 'Brown'];
const cities = { UK: 'York', IE: 'Cork', FR: 'Lyon', DE: 'Bremen', ES: 'Seville', IT: 'Turin', NL: 'Utrecht', SE: 'Malmö', US: 'Denver', CA: 'Halifax', AU: 'Perth', JP: 'Osaka', IN: 'Pune', BR: 'Recife' };
const plans = ['free', 'team', 'business'];
const tagSets = [['vip'], [], ['trade', 'reseller'], ['new'], ['at-risk'], ['vip', 'trade'], [], ['new', 'at-risk']];
const people = Array.from({ length: 40 }, (_, i) => {
const country = countries[(i * 5) % countries.length].value;
return {
name: `${first[i % first.length]} ${last[(i * 3) % last.length]}`,
email: `${first[i % first.length].toLowerCase()}@example.${country.toLowerCase()}`,
country,
address: { city: cities[country] },
age: 18 + ((i * 13) % 60),
plan: plans[(i * 5) % 3],
spent: Math.round(((i * 977) % 9000) + 120),
limit: [1000, 2500, 5000, 10000][i % 4],
joined: day(3 + ((i * 41) % 900)),
lastOrder: `${day((i * 5) % 60)}T${String(8 + (i % 10)).padStart(2, '0')}:15`,
member: i % 3 !== 0,
tags: tagSets[i % tagSets.length],
};
});
const qb = document.getElementById('ex-qb');
const read = document.getElementById('ex-qb-read');
const out = document.getElementById('ex-qb-out');
const tbody = document.querySelector('#ex-qb-table tbody');
qb.fields = fields;
read.fields = fields;
qb.value = {
combinator: 'and',
rules: [
{ field: 'country', operator: 'in', value: ['UK', 'IE', 'FR', 'DE'] },
{ field: 'age', operator: 'between', value: [25, 60] },
{
combinator: 'or',
rules: [
{ field: 'spent', operator: 'greater', value: 'limit', valueSource: 'field' },
{ field: 'tags', operator: 'hasAny', value: ['vip', 'trade'] },
],
},
{ field: 'joined', operator: 'inLast', value: { amount: 2, unit: 'year' } },
],
};
const label = value => countries.find(c => c.value === value)?.label ?? value;
const cell = (text, end) => {
const td = document.createElement('td');
td.textContent = text;
td.style.padding = '0.3rem 0.5rem';
td.style.borderBlockStart = '1px solid var(--bmx-border, #e5e9ef)';
if (end) td.style.textAlign = 'end';
return td;
};
const money = new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP', maximumFractionDigits: 0 });
async function refresh() {
const keep = await qb.toPredicate();
const rows = people.filter(keep);
tbody.replaceChildren(
...rows.map(p => {
const tr = document.createElement('tr');
tr.append(cell(p.name), cell(label(p.country)), cell(p.age, true), cell(money.format(p.spent), true), cell(money.format(p.limit), true), cell(p.plan), cell(p.joined), cell(p.tags.join(', ')));
return tr;
}),
);
out.textContent = `${rows.length} of ${people.length} customers match.`;
read.value = await qb.getQuery();
}
qb.addEventListener('bmxQueryChange', refresh);
await refresh();
document.getElementById('ex-qb-dialect').addEventListener('change', e => (qb.sqlDialect = e.target.value));
document.getElementById('ex-qb-case').addEventListener('change', async e => {
qb.ignoreCase = e.target.checked;
await refresh();
});
document.getElementById('ex-qb-check').addEventListener('click', async () => {
const errors = await qb.validate();
out.textContent = errors.length ? `${errors.length} rule${errors.length === 1 ? ' needs' : 's need'} attention: ${errors[0].message}` : 'Every rule is finished and valid.';
});
document.getElementById('ex-qb-clear').addEventListener('click', () => qb.clear());
const alert = document.getElementById('ex-qb-alert');
alert.fields = [
{ name: 'cpu', label: 'CPU (%)', type: 'number', min: 0, max: 100 },
{ name: 'memory', label: 'Memory (%)', type: 'number', min: 0, max: 100 },
{ name: 'host', label: 'Host name' },
{ name: 'env', label: 'Environment', type: 'list', options: [{ value: 'prod', label: 'Production' }, { value: 'test', label: 'Test' }] },
];
const form = document.getElementById('ex-qb-form');
const formOut = document.getElementById('ex-qb-form-out');
form.addEventListener('submit', e => {
e.preventDefault();
formOut.hidden = false;
formOut.textContent = `alertRule = ${new FormData(form).get('alertRule')}`;
});
</script>
The page describes its fields - a name, a label and a type (text, number, date, date and time, yes/no, a list of choices or several of them, or a custom type with an editor of its own) - and each field offers the conditions that suit it, with a value editor to match. Rules and groups are added, copied, switched off, removed, and moved by dragging or with the arrow keys.
The query is JSON in and out, and the builder writes it as a parameterised
SQL condition (ANSI, PostgreSQL, MySQL, SQL Server, SQLite, Oracle), a
MongoDB filter, an OData $filter, JsonLogic, a sentence, or a function that
filters an array in the page (toPredicate). The same conversions are
exported for a server. Nothing is sent anywhere.
Keyboard: on a rule's or group's grip, Up and Down move it through the query, into and out of groups, and say where it went. The AND/OR choice is a pair of radio buttons (arrow keys), and the preview's outputs are tabs.
Properties
| Property | Attribute | Type | Default | Description |
|---|---|---|---|---|
allowClone |
allow-clone |
boolean |
true |
Offers a button to copy a rule or a group. |
allowDisable |
allow-disable |
boolean |
true |
Offers a button to switch a rule or a group off without removing it. |
allowDrag |
allow-drag |
boolean |
true |
Rules and groups can be moved by dragging and with the arrow keys. |
allowGroups |
allow-groups |
boolean |
true |
Offers groups inside groups. |
allowNot |
allow-not |
boolean |
true |
Offers NOT on groups. |
combinators |
combinators |
string |
'and or' |
The joins offered: and or (default), and or or. |
disabled |
disabled |
boolean |
false |
Turns every control off. |
fields |
fields |
BmxQueryField[] | string |
[] |
The fields rules can test: [{ name, label, type, options }], as a property or JSON. |
ignoreCase |
ignore-case |
boolean |
true |
Text compares without case, in every conversion. |
label |
label |
string |
— | The builder's accessible name. |
locale |
locale |
string |
— | The language numbers and dates are written in. The reader's, by default. |
maxDepth |
max-depth |
number |
5 |
The most levels of groups, the outer one included. |
name |
name |
string |
— | With a name, the query goes into an enclosing <form> as JSON under it. |
operators |
property only | BmxQueryOperator[] |
— | Operators of the page's own, or conversions for built-in ones. A property. |
preview |
preview |
string |
'' |
Outputs to show under the builder, in tabs: sql mongo odata jsonlogic json text. None, by default. |
readonly |
readonly |
boolean |
false |
Shows the query as sentences rather than as controls. |
required |
required |
boolean |
false |
An enclosing form will not send without at least one finished rule. |
searchThreshold |
search-threshold |
number |
12 |
With more fields or choices than this, the pickers can be typed into to search. |
sqlDialect |
sql-dialect |
BmxQuerySqlDialect |
'ansi' |
The SQL the preview shows and toSql writes by default. |
strings |
strings |
Partial<BmxQueryBuilderStrings> | string |
— | Replacements for the builder's wording, as a property or JSON. |
validateOn |
validate-on |
'blur' | 'change' | 'submit' |
'blur' |
When a rule shows its problem: once its value is left (blur), as it changes (change), or only on validate() and form sending (submit). |
value |
value |
BmxQueryGroup | string | null |
null |
The query, as a property or JSON. Changes write back here, with ids. Queries from jQuery QueryBuilder and react-querybuilder are read too. |
Events
| Event | Detail | Description |
|---|---|---|
bmxQueryChange |
BmxQueryChangeDetail |
The query changed. |
Methods
| Method | Signature | Description |
|---|---|---|
addGroupTo |
addGroupTo(groupId?: string, group?: Partial<BmxQueryGroup>) => Promise<string> |
Adds a group to a group (the outer one by default) and gives its id. |
addRuleTo |
addRuleTo(groupId?: string, rule?: Partial<BmxQueryRule>) => Promise<string> |
Adds a rule to a group (the outer one by default) and gives its id. |
clear |
clear() => Promise<void> |
Removes every rule. |
getQuery |
getQuery(keepIds?: boolean) => Promise<BmxQueryGroup> |
The query as JSON: with ids (default), or without. |
removeItem |
removeItem(id: string) => Promise<void> |
Removes a rule or a group. |
setFocus |
setFocus() => Promise<void> |
Puts focus on the first control. |
setQuery |
setQuery(query: unknown) => Promise<void> |
Replaces the query. Queries from jQuery QueryBuilder and react-querybuilder are read too. |
toJsonLogic |
toJsonLogic(options?: BmxQueryConvertOptions) => Promise<unknown> |
The query as JsonLogic. |
toMongo |
toMongo(options?: BmxQueryMongoOptions) => Promise<Record<string, unknown>> |
The query as a MongoDB filter. |
toOData |
toOData(options?: BmxQueryODataOptions) => Promise<string> |
The query as an OData $filter expression. |
toPredicate |
toPredicate(options?: BmxQueryConvertOptions) => Promise<(record: unknown) => boolean> |
A function that says whether a record matches: rows.filter(await qb.toPredicate()). |
toSql |
toSql(options?: BmxQuerySqlOptions) => Promise<BmxQuerySql> |
The query as a parameterised SQL condition, in sql-dialect unless the options say otherwise. |
toText |
toText(options?: BmxQueryTextOptions) => Promise<string> |
The query as a sentence. |
validate |
validate() => Promise<BmxQueryError[]> |
Every problem, shown on the rules. An empty list when the query is valid. |
CSS shadow parts
| Part | Description |
|---|---|
builder |
the outer box. |
code |
the output itself. |
error |
|
group |
a group of rules. |
group-header |
a group's join, NOT and buttons. |
handle |
the grip a rule or group is moved by. |
items |
the list of a group's rules and groups. |
preview |
the output panel. |
rule |
a rule's row. |
CSS custom properties
| Property | Description |
|---|---|
--bmx-query-builder-accent |
The chosen join, the rail and the drop marker. |
--bmx-query-builder-background |
Behind the outer group. |
--bmx-query-builder-border |
Lines around groups and the preview. |
--bmx-query-builder-code-background |
Behind the preview's output. |
--bmx-query-builder-group-background |
Behind a group inside another. |
--bmx-query-builder-not-color |
A group's NOT when it is on. |
--bmx-query-builder-or-accent |
The rail and join of an OR group. |
--bmx-query-builder-radius |
Corners of groups and rules. |
--bmx-query-builder-rule-background |
Behind a rule's row. |