Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 57 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,59 @@ auto-generated per-PR notes; this file is the curated, human-readable history.
## [Unreleased]

### Added
- **An `Array(T)` Dashboard variable with option SQL is a searchable
multi-select** (#468). `Array(T)` is the type multi-select exists for, but #447
phase 1 removed the curated model's multiselect control as an owner decision and
phase 2 classified every container as having no inferred control — so a variable
like `user : Array(String)`, with a perfectly good option query configured,
rendered a free-text box and never ran that query. The #189/PR-#364 control is
back, now driven by the variable's own Dashboard-local option SQL.

The closed trigger reads **`Not set`**, the single option's **label**, or
**`N selected`**. The popover offers a labelled search over both labels and
values, a tri-state **Select visible** scoped to whatever the search currently
shows (hidden rows are never touched), per-option checkboxes, and **Clear /
Cancel / Apply**. Nothing commits until Apply, which canonicalizes by option
order, fires at most once, and re-runs only the panels declaring that variable —
a no-op Apply issues nothing. Cancel, Escape and clicking outside discard the
draft and return focus to the trigger.

Selections stay **real arrays** end to end — through viewer state, the
per-Dashboard store, and the existing typed `Array(T)` serializer, which binds
`param_user=['ada','bo']` with quotes, backslashes, Unicode and big integers all
escaped, never a joined `"ada,bo"`. An **empty** selection is unset (the panels
wait) rather than a literal `[]`, which would return nothing while looking
filtered. Selections survive a reload.

A committed selection is **never silently changed**. While the option batch is
in flight the control is inert (`Loading options…`) — the Dashboard is mounted
before the request finishes, so an Apply against a list that had not arrived
would otherwise clear a restored selection. An automatic refresh only ever
**removes** values that are genuinely gone, in **one** coalesced wave, and
preserves the committed ORDER: the array binds as an ordered ClickHouse literal
and panel SQL may read it positionally, so re-sorting it to match a new option
order would change what panels bind without re-running them. A label-only or
option-order-only change therefore does nothing at all. If the option list came
back **truncated** at the 1,000 cap, nothing is pruned at either end — a
selected value may simply live past the cap, so the refresh leaves it alone
*and* **Apply keeps it** rather than dropping it for being absent from a list
that is known to be a prefix. (**Clear** still removes everything, and with a
complete list an absent value is genuinely gone and does get dropped.) New
options are never auto-selected.

Eligibility is one pure predicate (`multiSelectElementType`) shared by the option
batch and the control dispatch, so a type whose option SQL ran can never be one
the bar refuses to render a select for. `Tuple`, `Map`, `Nested` and nested
`Array(Array(…))` are unchanged: no option query, no select, and the same
free-text field plus marker as before. An `Array(scalar T)` with **no** option SQL
also keeps that field, but its marker now names the fix ("add option SQL to pick
from a list") instead of calling a controllable type uncontrollable.

**Fixes a latent bug:** every `Array(T)` variable with valid option SQL was
silently marked `status: 'error'` by a branch commented "unreachable via
`optionBatchVariables`' own rule" — which was wrong, and invisible only because
the select it would have applied to never rendered.

- **Dashboard variables can offer a list of values, and every list on a Dashboard
loads in one request** (#447, phase 2). A variable (inferred from the
`{name:Type}` placeholders in its panels' SQL) may carry optional
Expand Down Expand Up @@ -37,7 +90,10 @@ auto-generated per-PR notes; this file is the curated, human-readable history.

Cascading option queries are rejected outright, and `Array`/`Tuple`/`Map`/
`Nested` variables are marked as having no inferred control — they keep their
text input, since a literal typed there still binds.
text input, since a literal typed there still binds. (**Narrowed by #468
above**, in this same unreleased set: an `Array` of a scalar WITH option SQL now
renders a multi-select. The marker survives for every other container, and for
an `Array(scalar T)` nobody has configured.)

### Changed
- **Dashboard variable option SQL is edited in the main editor, as its own tab**
Expand Down
34 changes: 30 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -533,10 +533,36 @@ icon.

Option SQL must be one embeddable read query returning exactly two `String`
columns — value, then visible label, by POSITION rather than by column name — and
may not reference Dashboard variables itself. A variable with option SQL renders a
strict single-select; one without keeps the direct input inferred from its declared
type. Every configured variable on a Dashboard is compiled into a single
`UNION ALL` request per refresh, so ten of them still cost one round trip.
may not reference Dashboard variables itself. Every configured variable on a
Dashboard is compiled into a single `UNION ALL` request per refresh, so ten of them
still cost one round trip.

Which control a configured variable renders follows from its declared type:

- a **scalar** gets a strict single-select over those options;
- an **`Array` of a scalar** (`Array(String)`, `Array(UInt64)`, …) gets a
**searchable multi-select**: a closed trigger reading `Not set` / the single
option's label / `N selected`, and a popover with a search box, a tri-state
**Select visible** scoped to whatever the search currently shows, per-option
checkboxes, and **Clear / Cancel / Apply**. Nothing commits until Apply, which
re-runs only the panels declaring that variable; Cancel, Escape and clicking
outside discard the draft. The selection binds as a real ClickHouse array
literal, so quotes, backslashes, Unicode and big integers are all escaped by the
same typed serializer everything else uses. Selections persist across a reload.

Your selection is never changed behind your back: the control stays inert
(*Loading options…*) until its list arrives, a refresh only ever **removes**
values that are genuinely gone — keeping the order you committed, since the
array binds positionally — and if the option list came back truncated at the
1,000 cap, nothing is removed at all: a selected value may simply live past the
cap, so it survives both the refresh *and* your next Apply. **Clear** removes
everything, including values the list is too short to show. If every selected
value disappears from a complete list, the variable returns to unset.

A variable with **no** option SQL keeps the direct input inferred from its declared
type. `Tuple`, `Map`, `Nested` and nested `Array(Array(…))` have no inferable
control at all and always keep a free-text field, where a hand-typed literal such
as `['a','b']` still binds.

## Local install

Expand Down
117 changes: 114 additions & 3 deletions docs/ADR-0003-dashboard-viewing.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

- **Status:** Accepted; detached-snapshot decision superseded by #407 on
2026-07-23; surface lifecycle amended by #425 and surface NAVIGATION amended by
#426, both 2026-07-25 (see the addenda)
- **Date:** 2026-07-18; revised 2026-07-23, 2026-07-25
- **Context tracking:** roadmap #68; #288, #302, #406, #407, #425
#426, both 2026-07-25; the #447 phase-2 compound-type exclusion narrowed by #468
on 2026-07-26 (see the addenda)
- **Date:** 2026-07-18; revised 2026-07-23, 2026-07-25, 2026-07-26
- **Context tracking:** roadmap #68; #288, #302, #406, #407, #425, #447, #457, #468

## Context

Expand Down Expand Up @@ -325,6 +326,9 @@ are worth recording because both contradict something the issue's own text impli
already validates. Only the compound-type case is new — and it *adorns* the input
rather than replacing it, because `param-serialize` binds an array literal typed
there and removing the field would leave those panels permanently unfillable.
**Narrowed by the addendum below:** an `Array` of a scalar WITH option SQL now
renders a multi-select instead; the adornment survives for every other container
and for an `Array(scalar T)` nobody has configured.

## Addendum (#457, 2026-07-26): a variable's option SQL is a main-editor document

Expand Down Expand Up @@ -374,6 +378,113 @@ the application already has. Four decisions replace it.
the Dashboard, not on that poke. The tree's orphan-delete still fires it for real,
because the tree is visible while a Dashboard is.

## Addendum (#468, 2026-07-26): an `Array(scalar T)` variable binds a selection

#447 phase 1 removed the curated filter model wholesale, and its non-goals listed
"multiselect or Array-valued variable controls". Phase 2 then classified every
container type as having no inferred control. That left the one type multi-select
exists for — `Array(T)` — as a free-text box, even with a working option list
configured. This addendum records the deliberate reversal for the narrow case, and
restores the #189/PR-#364 control on top of the inferred-variable model.

- **One predicate decides eligibility, and both consumers read it.**
`multiSelectElementType` (`core/param-type.ts`) answers "is this an `Array` of a
single scalar, and what is the element type" for `core/variable-options.ts`'s
batch filter AND for `fieldControlKind`'s control choice. A type whose option SQL
ran can therefore never be one the bar refuses to render a select for, which is
the same invariant `filter-bar.ts` already stated for the container verdict.
`Tuple`/`Map`/`Nested` have no flat element list; `Array(Array(T))` is rejected by
`param-serialize` outright. All four keep the adorned text field.

- **`fieldControlKind` classifies the TYPE; the bar pairs it with the spec.** The
pure function has no way to know whether option SQL was configured, and giving it
one would mean passing UI state into a parameter-analysis helper. `'multi'` means
"this type can be multi-selected"; `filter-bar.ts` combines that with
`spec.options !== null`, and an `Array(scalar T)` with no options falls back to
the same adorned input as before — with wording that names the fix ("add option
SQL") instead of calling a controllable type uncontrollable.

- **A selection is a real `string[]`, never a stringified literal.**
`param-serialize.ts` already builds the ClickHouse literal from a JS array, with
escaping, big integers and empty-string elements covered and tested. Committing a
pre-serialized string instead would put literal construction in the UI, and would
make the committed value unparseable back into a selection for the popover to
re-open on. `dashboard-filter-store.ts` never lost its `string | string[]`
support, so persistence needed no change at all.

- **The string boundary is `state.varValues`, and it is enforced by its TYPE.**
Arrays travel: viewer session → `ViewerFilterState.value` (already `unknown`) →
`VariableFieldSpec.selection` → the control → `onCommitVariableSelection` → back.
They never enter `FilterBarApp.state.varValues`, which stays
`Record<string, string>` because the Workbench var-strip owns and persists that
same bag under `asb:varValues`. Widening it would NOT have been a safeguard —
TypeScript's property assignability is covariant even for mutable properties, so
a widened type would still accept the real `AppState` while letting an array
through. Keeping it narrow is the enforcement.

- **An empty selection is unset, not `[]`.** `param-pipeline`'s `emptyValue()`
treats a present `[]` as a genuine value, so binding one would run every panel as
`… IN []` — returning nothing while LOOKING filtered — where a variable's unset
contract is that its panels wait. `commitValue` reduces it to `UNSET_VALUE`, so
there is exactly one unset form. This deliberately narrows #189, which could
express an "active empty array"; with no defaults and no dormant values, no
control here can author one.

- **Reconciliation returns names; `refresh` runs one wave.** `applyOptions` reports
which variables a fresh option list actually changed the bound SET for (a pure
reorder or a label-only change reports nothing), `runOptionBatch` collects them,
and `refresh` runs a single `commitAndRerun` over the union AFTER both the option
request and the tile pool have settled. Re-running inside `runOptionBatch` would
supersede tiles mid-refresh and make the outcome classifier judge tiles that are
already re-running. One coalesced wave is structural, not a flag to remember.

- **A committed selection is never silently changed** — three separate ways it
could have been, all found in review and all closed:

- *Applying before the options arrive.* `renderDashboard` mounts the surface
BEFORE awaiting `session.start()`, and a configured variable publishes with
`options: null` and no error, so the control was operable for the entire
request; a no-change Apply canonicalized a restored selection against the
empty list and committed a clear. The variable's `loading` status now reaches
the control, which stays inert until `setOptions` (the only thing that clears
it) or a batch failure. This is the one piece of #189's status machine with a
reason to exist that survived the trim.
- *Re-canonicalizing on refresh.* `reconcileSelection` preserves the COMMITTED
order and only filters. The array binds as an ORDERED literal and panel SQL
may read it positionally (`arrayElement`, a positional join) — `{name:Array(T)}`
promises nothing about membership semantics — so adopting a new option order
would change what panels bind while reporting no wave, and persist the
difference. The user's own Apply still canonicalizes: that is a deliberate
action taken against a list they are looking at.
- *Pruning against a capped list.* A value can live past the 1,000-option cap,
so a truncated result is not evidence that anything was removed; reconciliation
is skipped entirely for one (the warning still publishes). The truncation
SIGNAL was itself unsound — derived from the KEPT count, it missed a branch
whose 1,001 rows collapsed under the cap through dedup or blank filtering
(#461) — and now counts RAW rows against the branch `LIMIT`, which is what
actually says the server cut the result off. The single-select already kept an
off-list committed value verbatim; a selection gets the same benefit of the
doubt.

**Incompleteness is published, not private.** The session declining to prune
is undone if the CONTROL's own Apply then canonicalizes the same value away
against the same partial list — `canonicalizeSelection` drops everything the
list does not offer, so a no-change Apply committed `([], false)` and a
single visible pick silently dropped the rest. So `optionsTruncated` rides on
`ViewerFilterState` down to the control, whose Apply keeps draft values the
list does not contain, appended in committed order. They are invisible — no
row exists for them — so the user cannot have deselected one, and the list is
known to be a prefix, so it cannot be called stale. **Clear** still removes
them: it empties the whole draft, which is the explicit "remove everything".
With a COMPLETE list the rule does not apply at all — an off-list value has
genuinely gone, and the session has already reconciled it out.

- **A latent bug fell out.** `dashboard-viewer-session.ts` marked every `Array(T)`
variable with valid option SQL as `status: 'error'` via a branch commented
"unreachable" — true only for the types that genuinely cannot be option-backed.
Admitting `Array(scalar T)` into the batch made the comment honest, and the
message now says what is actually wrong.

## Alternatives considered

- **Durable detached snapshots:** rejected because they silently diverge from
Expand Down
24 changes: 19 additions & 5 deletions src/core/param-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ import { splitStatements as _splitStatements, isRowReturning as _isRowReturning
import { scanParamDeclarations } from './param-scan.js';
import type { ParamDeclaration } from './param-scan.js';
import {
parseParamType, conflictingTypes, enumValues, isCompoundParamType, typeLexKind,
parseParamType, conflictingTypes, enumValues, isCompoundParamType, multiSelectElementType,
typeLexKind,
} from './param-type.js';
import type { ParsedParamType } from './param-type.js';
import { serializeParamValue as _serializeParamValue } from './param-serialize.js';
Expand Down Expand Up @@ -693,10 +694,11 @@ export function fieldControls(analysis: ParameterAnalysis): FieldControl[] {
}

/** `fieldControlKind`'s return shape — which control a `fieldControls` entry
* renders, and (for `'enum'`) the member list to offer. `'unsupported'` is
* reachable only under the scalar-controls policy (see `fieldControlKind`). */
* renders, and (for `'enum'`) the member list to offer. `'multi'` and
* `'unsupported'` are reachable only under the scalar-controls policy (see
* `fieldControlKind`). */
export interface FieldControlKindResult {
kind: 'enum' | 'date' | 'text' | 'unsupported';
kind: 'enum' | 'date' | 'text' | 'multi' | 'unsupported';
enumOptions: string[] | null;
}

Expand All @@ -713,7 +715,14 @@ export interface FieldControlKindOptions {
* text, because ClickHouse's Bool accept-set is not enumerable —
* `yes`/`no`/`on`/`off`/`1`/`0` all work — so the list is a hint, not a
* constraint;
* - a COMPOUND type (`Array`/`Tuple`/`Map`/`Nested`) resolves to
* - an `Array` of a SCALAR resolves to `'multi'`: several option rows
* combine into one bound array, which is exactly what the restored #189
* multi-select does. This says the TYPE can be multi-selected, not that
* this variable has an option list to select from — the bar pairs the
* verdict with the option spec one layer up, because only the bar knows
* whether option SQL was configured;
* - every OTHER compound type (`Tuple`/`Map`/`Nested`, and a nested
* `Array(Array(T))` the serializer rejects outright) resolves to
* `'unsupported'`: there is no single-scalar control for a container, and
* saying so is better than rendering a box that cannot produce a valid
* value. The value pipeline itself still handles these types fine, which is
Expand Down Expand Up @@ -750,6 +759,11 @@ export function fieldControlKind(
if (options.scalarControls) {
// Checked AFTER enum/date so the priority order stays single-sourced: a
// declaration is only ever compound when neither of those claimed it.
// `multi` is tried FIRST among the container shapes — it is the narrower
// rule, and `multiSelectElementType` is the one predicate the option batch
// (`core/variable-options.js`) filters on, so a type that gets a select can
// never be one whose option SQL was skipped.
if (multiSelectElementType(field.type)) return { kind: 'multi', enumOptions: null };
if (isCompoundParamType(field.type)) return { kind: 'unsupported', enumOptions: null };
if (typeLexKind(field.type) === 'bool') return { kind: 'enum', enumOptions: BOOL_CONTROL_OPTIONS };
}
Expand Down
Loading
Loading