Filters and selections
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 one selection group
Section titled “Use one selection group”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.
Field metadata for connected selections
Section titled “Field metadata for connected selections”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.
Selections on semantic layer items
Section titled “Selections on semantic layer items”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.
Apply pending selections
Section titled “Apply pending selections”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.
Always show a selection bar
Section titled “Always show a selection bar”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,notBetweenandrangewith 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 anotoperator 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.
Build an apply bar
Section titled “Build an apply bar”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.
Read selections defensively
Section titled “Read selections defensively”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.
Periods and date ranges
Section titled “Periods and date ranges”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-pickerand 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-ddstrings, charts still filter but adate-pickerin the group showsNaN. Numeric strings are not compared as dates in the query. Verified with@astrato-io/visualizations0.6.5 and@astrato-io/sdk0.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’sdataTypeinavailableFieldsis a date and that the selection has nodatePart, 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 withnew Date(bound).toISOString().slice(0, 10). -
For a chart-local fixed date filter, follow the native
setStaticFilterGroupdate 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.
Keep comparison periods in scope
Section titled “Keep comparison periods in scope”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.
Limitations to design around
Section titled “Limitations to design around”- 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.