Skip to content

Filters and selections

9 min read · Updated 3 Nov 2026

This page belongs to Data App developer guide. How selection groups, pending and applied selections work is in the Interactivity guide; read it first. This page covers the decisions a whole dashboard needs on top of it.

Use the same selection group for charts, tables and filter objects that should filter each other. Queries your app sends directly do not join a group: translate its applied selections into query filters as described below. Use separate groups when objects need independent selections, such as two copies of a chart in a comparison.

Also use separate groups when objects do not share the selected field’s values. A selection becomes a filter on every connected object in the group. If one view holds national and regional rows, a region selection filters the national series to no rows. Group the objects that share a dimension, such as every regional chart with the region filter, and keep other objects in their own groups. A selection bar should then list the selections of every group it represents.

A filter object’s value list applies selections on other connected fields and excludes selections on its own field. Use a shared group so connected filters update together without custom synchronization. Separate groups require synchronization that handles feedback loops, accidental clears and empty selection events while filters open.

Cross-object filtering uses field metadata to check connections in the semantic layer’s join graph. The runtime can load that metadata from the client’s engine or data services before mounting and merge it with dataBinding.fieldMetadata. If your client has no metadata service, supply the snapshot yourself. Failed metadata requests are logged; a client with neither a metadata service nor supplied metadata cannot provide the connectivity information. See Prerequisite: load field metadata first.

Objects built on stored dimensions and measures follow selections in the same way as objects built on view columns. Before an object applies a selection from its group, it checks that the selected field is connected to its own fields; for a stored item, it checks the view column the item is defined on.

Selections identify the field by its view-column key. A chart or filter object built on a stored dimension writes the key of the view column that dimension is defined on, such as [Customers].[Country], rather than the dimension’s own key, such as [dimension].[Country]. Selections your code writes must do the same. A selection keyed by a stored item’s key is not applied: objects keep their current data, and the console logs could not find fieldKey.

Find a stored dimension’s view column from availableFields: its item.dimension names the column’s view and name.

import type { VisualizationAvailableField } from '@astrato-io/visualizations/definitions';
// The key that selections on this field carry.
const selectionKeyOf = (field: VisualizationAvailableField, availableFields: VisualizationAvailableField[]) => {
if (field.kind !== 'dimension') return field.fieldKey;
const { view, name } = field.item.dimension;
const column = availableFields.find((entry) => entry.kind === 'field' && entry.view === view && entry.name === name);
if (!column) throw new Error(`No view column found for ${field.name}.`);
return column.fieldKey;
};

This was verified with @astrato-io/sdk 0.5.0 and 0.6.0 and @astrato-io/visualizations 0.6.4 and 0.6.5.

Clicking a chart value writes a pending selection in either selection mode. So does a filter object, unless its quick select setting is on (@astrato-io/visualizations@0.5.1). The selection mode only sets the default for selectValues calls that pass no state; see Driving selections from your own UI. A pending selection highlights matching points but does not filter queries until applied. The visualization packages do not include an apply control.

Applying costs one query per mounted object in the group. Choose one of these:

  • Apply automatically, after a short debounce, so a click filters and a run of clicks costs one query per object. See Auto-apply variant. The bar below then needs only the chips and Clear all.
  • Build an apply bar, when readers should confirm a set of selections before anything re-queries. Call setSelectionMode('manual', GROUP) so selections your own code writes wait too.

Every page that readers can filter or click shows a selection bar, whichever apply model it uses. Without one, a reader cannot tell that a figure is filtered, or which click filtered it.

Every chart counts as something readers can click, whether or not you planned an interaction: a click writes a selection to the chart’s group and dims its other marks, and no operation turns that off. A chart in a group with no selection bar leaves the reader with dimmed marks and nothing to clear them, so keep a chart in the page’s group, or show a bar for its own group beside it. A drill that opens another page needs no bar for that click: its way back is the back link.

  • Place it in a thin row under the header that sticks with the header, so it stays visible while the page scrolls. Reserve its height even when it is empty.
  • Show the selections of the group on screen: one chip per field, with the field name and what it selects, read from its operator: between, notBetween and range with two values are ranges, so show both bounds rather than a count; otherwise show the value, or a count when there are several, and say so when a not operator excludes them. Date values need formatting first. When tabs use separate groups, render it again on every tab switch.
  • Give each chip a remove control (removeSelection(GROUP, selection)), and show Clear all when anything is selected.
  • When nothing is selected, show a short hint that is true of this page rather than collapsing the row: “Nothing selected.” on any page, or a way to select only where the page has one.
  • Include selections written by chart clicks, not only those from filter objects.
// What a chip says. Only the field's type shows that a number is a date.
const selectionText = (s, availableFields) => {
const column = availableFields.find((entry) => entry.fieldKey === s.field.fieldKey);
const onDate = /^(date|timestamp)/u.test(column?.dataType ?? '') && !s.datePart;
const [first, second] = s.values.map((value) => {
const date = onDate && typeof value === 'number' ? new Date(value) : undefined;
return date && !Number.isNaN(date.getTime()) ? date.toISOString().slice(0, 10) : String(value);
});
const not = s.operator?.startsWith('not') ? 'not ' : '';
if (s.values.length === 1) return `${not}${first}`;
if (s.values.length === 2 && ['between', 'notBetween', 'range'].includes(s.operator))
return first === second ? `${not}${first}` : `${not}${first} to ${second}`;
return `${s.values.length} ${not ? 'excluded' : 'selected'}`;
};
const renderSelectionBar = () => {
const { applied, pending } = selections.getSelection(GROUP);
// One chip per field: a pending replacement wins over the applied selection it replaces.
const byField = new Map();
for (const s of [...applied, ...pending]) byField.set(s.field.fieldKey, s);
const list = [...byField.values()].filter((s) => s.values?.length);
bar.replaceChildren(
...(list.length
? list.map((s) =>
chip(fieldLabel(s.field.fieldKey), selectionText(s, availableFields), () =>
selections.removeSelection(GROUP, s),
),
)
: [hint('Nothing selected.')]),
...(list.length ? [clearAll(() => selections.clearAllSelections(GROUP))] : []),
);
};
selections.onActiveSelectionChange(GROUP).subscribe(renderSelectionBar);

The apply bar below is this selection bar with Discard and Apply added.

Reserve a fixed height for the apply bar to prevent layout shifts. Include:

  • a chip per selection, written as in the selection bar;
  • Clear all beside the chips when anything is selected;
  • Discard and Apply at the end, shown only while selections are pending, so the bar indicates which changes still need confirmation.
selections.onActiveSelectionChange(GROUP).subscribe(({ applied, pending }) => {
renderChips(applied, pending);
applyControls.hidden = pending.length === 0;
});
applyButton.addEventListener('click', () => selections.applyPendingSelections(GROUP));
discardButton.addEventListener('click', () => selections.clearPendingSelections(GROUP));
clearAllButton.addEventListener('click', () => selections.clearAllSelections(GROUP));

Re-query your own objects only on applied changes

Section titled “Re-query your own objects only on applied changes”

Figure cards and other hand-built objects must follow the same filters as the charts. Re-query them when the applied selections change, not on every emission: pending state changes on every brush and filters nothing. Compare a signature of the applied list so repeated emissions of the same state do not re-query:

let lastApplied = '';
selections.onActiveSelectionChangeApplied(GROUP).subscribe(({ applied }) => {
const signature = JSON.stringify(applied.map((s) => [selectionFieldKey(s), s.operator, s.values]));
if (signature === lastApplied) return;
lastApplied = signature;
refreshFigures(applied);
});

To filter a query you build yourself, translate the applied VALUE selections into its filters. A query filter takes typed values, so a selection of text values becomes:

import type { AstratoSelection } from '@astrato-io/sdk';
const toQueryFilter = (selection: AstratoSelection) => ({
fieldKey: selectionFieldKey(selection),
operator: selection.operator ?? 'inList',
values: selection.values.map((value) => ({ dataType: 'string', valueString: String(value) })),
});

Date ranges use valueDate bounds instead; see Periods and date ranges. A selection from a chart on a grouped date also carries dateLevel or datePart: copy it onto the filter, so the value is compared at that grain. In refreshFigures, ignore results from superseded requests so a slow earlier query cannot overwrite the figures for the current selections.

Write selections in the documented AstratoSelection shape. Selections written by charts carry more than that shape (see Driving selections from your own UI), and producers do not all put the field key in the same place. Read it from field.fieldKey and fall back to a key on the selection itself:

const selectionFieldKey = (selection: { field?: { fieldKey?: string }; fieldKey?: string }) =>
selection.field?.fieldKey ?? selection.fieldKey;

Use this lookup consistently so hand-built figures receive selections from each producer. Compare the result with selectionKeyOf from Selections on semantic layer items, not with a stored dimension’s own fieldKey, which selections never carry. Compare full keys; do not parse names or key suffixes.

A dashboard period — this month, year to date, a custom range — is usually best expressed as an applied selection on the date field in the shared group, so every object follows it the same way as any other filter. Measures that take date parameters need those values as well; send them as parameters on the queries you build.

  • A date range selection holds its bounds as UTC epoch milliseconds, as numbers, inclusive at both ends. A mounted date-picker and a chart on a date field write this shape, and the picker reads it back, so send the same one from your own period buttons:

    selections.selectValues(GROUP, {
    field: { fieldKey },
    fieldKey,
    operator: 'between',
    values: [Date.parse('2019-01-01T00:00:00.000Z'), Date.parse('2019-12-31T23:59:59.999Z')],
    });

    Send numbers only. With yyyy-mm-dd strings, charts still filter but a date-picker in the group shows NaN. Numeric strings are not compared as dates in the query. Verified with @astrato-io/visualizations 0.6.5 and @astrato-io/sdk 0.6.0.

  • To show a selection’s range, convert its bounds: a range picked from 1 to 8 May 2024 has values: [1714521600000, 1715212799999]. Check first that the field’s dataType in availableFields is a date and that the selection has no datePart, whose values are plain numbers; a number range on a number field looks the same.

  • In a query you build, the same range is a filter with typed bounds: { fieldKey, operator: 'between', values: [{ dataType: 'date', valueDate: '2019-01-01' }, { dataType: 'date', valueDate: '2019-12-31' }] }. Convert a selection’s bound with new Date(bound).toISOString().slice(0, 10).

  • For a chart-local fixed date filter, follow the native setStaticFilterGroup date values.

  • Anchor relative periods on the latest date in the data, not today, when the data stops before today: on a historical extract every period counted back from today is empty.

A comparison calculated across answer rows, such as an inter-record change, needs the current and baseline periods in its input. Filtering that input to one period removes the preceding row; a shared date selection can do this. Check the governed question’s time scope before applying that selection to comparison objects.

When detail views show only the selected period, synchronize comparison queries with the picker’s applied state while retaining their baseline. A separate selection group can isolate the page’s date selection, but does not choose the comparison’s current period. Keep relevant non-date filters aligned between the detail and comparison views.

For measures that declare current and comparison date parameters, update both ranges from the applied period and omit the date selection from that query’s filters. Follow the period picker’s parameter workflow. For the inter-record KPI pattern, bound the input through the selected period while retaining the required baseline rows and preserving the question’s period grain. Without that cutoff, its descending query with a one-row limit shows the latest available period. Confirm the returned current period matches the selection; report missing period data rather than presenting an earlier period as the selected one.

Keep the calculation in a stored question or measure, and label the compared periods and any scope that differs from the detail views. Verify both periods and the comparison result after changing the date filter, including a historical period and a custom range when supported by the question and picker. See Period axes and comparisons for engine time-intelligence calculations.

  • Selections live in memory and are lost on reload. Persist a period or filter state yourself if the dashboard needs it.
  • Objects that summarize selections render blank standalone. Show selection state in your own bar.

See Limitations for the full list.