feat(#468): restore the searchable multi-select for Array(scalar T) Dashboard variables - #469
Conversation
…ariables `Array(T)` is the type a 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 type as having no inferred control. A `user : Array(String)` variable with a working option query stored its SQL, ran it fine in its own `Variable: user` tab, and still rendered a free-text box — its option SQL was never batched and no list ever appeared. Restores the #189/PR-#364 control on the inferred-variable model: search over labels AND values, tri-state Select visible scoped to the filtered subset, Clear/Cancel/Apply with draft-until-Apply, close-before-commit, focus return, and refresh reconciliation by bound value. Design: - ONE pure predicate, `multiSelectElementType`, is read by both the option batch and `fieldControlKind`, so a type whose option SQL ran can never be one the bar refuses a select for. `Tuple`/`Map`/`Nested`/`Array(Array(T))` are unchanged. - `fieldControlKind` classifies the TYPE (`'multi'`); `filter-bar.ts` pairs that with the spec, because only the bar can see whether option SQL was configured. - Selections are real `string[]` end to end and bind through the existing typed serializer. They never enter `FilterBarApp.state.varValues`, which stays `Record<string, string>` — TS property assignability is covariant even for mutable properties, so keeping that type narrow IS the enforcement, not a comment. - An empty selection reduces to UNSET rather than `[]`: `emptyValue()` treats a present `[]` as a real value, so binding one would run panels as `IN []` — nothing returned, but looking filtered. - Reconciliation returns names; `refresh` runs one coalesced wave after both the option request and the tile pool settle. Fixes a latent bug: every `Array(T)` variable with valid option SQL was marked `status: 'error'` by a branch commented "unreachable", invisible only because the select it applied to never rendered. Verified live against github.demo.altinity.cloud: 13 options load, search narrows to 5, Apply binds `param_user=['btyshkevich@altinity.com',...]` as a real ClickHouse literal, and the selection survives a reload. Reconciles #447's non-goals/control matrix/phase-1 gate, ADR-0003's phase-2 addendum, CHANGELOG, README and roadmap #68. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BF6uJrfy51KgkeTvfzyWDB
All three reproduced before fixing. 1. Apply during the option request cleared a restored selection. `renderDashboard` mounts the surface BEFORE awaiting `session.start()`, and a configured variable publishes with `options: null` (rendered as `[]`) and no error — so the control was fully operable for the whole request. A no-change Apply canonicalized both the draft and the committed selection against the empty list and committed `([], false)`. Verified: trigger read "2 selected", popover opened, `onApply` fired with `[]`. The variable's `loading` status now reaches the control, which renders inert (`aria-disabled`/`aria-busy`, "Loading options…") until `setOptions` — the only thing that clears it — or a batch failure ends the wait. This restores the one piece of #189's status machine that had a real reason to exist; I had dropped it with the Filter-source cascade states it shipped alongside. 2. An option-ORDER change rewrote the bound array with no rerun. Reconciliation re-canonicalized survivors into the new option order while reporting `waveNeeded: false`, so the persisted/session value silently diverged from the one that produced the displayed results. `{name:Array(T)}` promises nothing about membership semantics — panel SQL may read the array positionally — so `reconcileSelection` now preserves committed order and only ever FILTERS. The user's own Apply still canonicalizes; that is a deliberate action against a list they can see. 3. A server-capped option list deleted valid selections. A value living past row 1,000 was treated as removed: dropped, panels rerun, shortened array persisted. Reconciliation is now skipped entirely for a truncated result (the warning still publishes), matching the single-select, which already keeps an off-list value verbatim. The truncation SIGNAL was also unsound, which #461 sharpens: it derived from the KEPT count, so a branch whose 1,001 rows collapsed under the cap through dedup/blank filtering looked complete. It now counts RAW rows against the branch LIMIT, which is what actually says the server cut the result off. Verified live: the trigger goes "Loading options… (disabled)" -> "5 selected", a click during that window is refused, and the restored selection binds intact as ['default','demo','altinity','updater','dmitrii']. 5591 tests, 100% statements + lines; 15 multi-select e2e across all three engines. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BF6uJrfy51KgkeTvfzyWDB
CHANGELOG, ADR-0003 addendum and README updated for the three review fixes: the loading guard, order-preserving reconciliation, and no pruning against a truncated option list. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BF6uJrfy51KgkeTvfzyWDB
Review findings verified and fixed — all three were realI reproduced each with a throwaway probe before touching anything:
Finding 1's premise also checks out: Fixed in 1 — loading guardThe variable's Kept 2 — order-preserving reconciliation
3 — no pruning against an incomplete listReconciliation is skipped entirely when a variable's option result was truncated; the warning still publishes. This matches the single-select, which already keeps an off-list value verbatim. Your #461 note was the sharper half, and I fixed that too: the truncation signal derived from the kept count, so a branch whose 1,001 rows collapsed back under the cap through dedup/blank filtering looked complete. It now counts raw rows against the branch Tests
Live re-verificationSeeded a 5-value selection and had an init script attempt the destructive click during the loading window: The click was refused and the selection bound intact. |
…list Reproduced: with `selected:['u5000']` against a capped list, the trigger showed `u5000` correctly, but a NO-CHANGE Apply committed `([], false)`, and picking one visible value committed `['u1']` — dropping u5000 silently. The previous fix stopped the SESSION pruning against an incomplete list, but left the control canonicalizing against the same partial list on Apply, which undid the preservation one layer up. Both ends need the same fact, so `optionsTruncated` is now published on `ViewerFilterState` and threaded through the spec and `setVariableOptions` to the control. Apply now keeps draft values the list does not offer, appended in committed order, when and only when the list is a prefix. Such a value is invisible — no row exists for it — so the user cannot have deselected it, and a list known to be cut off cannot call it stale. Clear still removes them: emptying the whole draft is the explicit "remove everything". With a COMPLETE list the rule does not apply — the value is genuinely gone and the session has already reconciled it out. Also added `optionsTruncated` to the options signature in dashboard.ts: it changes how the control COMMITS, so a flip must reach it even when the option content it accompanies is byte-identical. Tests: 8 control-level cases (verbatim display, no-change Apply, single pick, several off-list values in order, Clear, complete-list contrast, setOptions toggling the flag, default), the session publishing the flag both ways, and the reviewer's requested shell-level case — a real 1,001-row response through renderDashboard with an off-list persisted value and a no-change Apply. 5601 tests, 100% statements + lines. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BF6uJrfy51KgkeTvfzyWDB
Confirmed and fixed — my previous fix was incompleteYou're right, and this was a real gap in my own fix rather than a new issue. Reproduced first: I stopped the session pruning against an incomplete list but left the control canonicalizing against that same partial list on Apply — so the preservation was undone one layer up. Both ends need the same fact; keeping "incomplete" private to the session was the mistake. Fixed in What changed
The reasoning is the one you gave: such a value is invisible (no row exists for it), so the user cannot have deselected it, and a list known to be cut off cannot call it stale.
I also added TestsYour requested case is in at the shell level ( Plus 8 control-level cases: verbatim display, no-change Apply, single visible pick, several off-list values preserved in committed order, Clear, the complete-list contrast, 5601 tests, 100% statements + lines; 15 multi-select e2e across three engines. One honest gapI verified the complete-list path live (an unknown persisted value correctly reconciles to unset). I did not verify the truncated path in the browser — the demo cluster's |
Closes #468.
The bug you hit
user : Array(String)on the ClickHouse Operations dashboard, given a working option query, stored its SQL correctly and returned 13 rows in its ownVariable: usertab — but the Dashboard rendered a free-text box, never ran that query, and said "Array(String)is a container type".Array(T)is the type a multi-select exists for. #447 phase 1 removed the curated model's multiselect as an owner decision, and phase 2 classified every container as having no inferred control. The value pipeline never lost the ability to serve this:param-serialize.tshas always turned a JSstring[]into a ClickHouse literal, anddashboard-filter-store.tssurvived #447 with itsstring | string[]round-trip intact.What this restores
The #189 / PR #364 control, now driven by a variable's own Dashboard-local option SQL: closed trigger reading
Not set/ the single label /N selected; popover with a labelled search over labels and values, a tri-state Select visible scoped to the filtered subset, per-option checkboxes, and Clear / Cancel / Apply. Draft-until-Apply, close-before-commit, focus return, and refresh reconciliation by bound value.Design decisions worth reviewing
multiSelectElementType(core/param-type.ts) is read by bothvariable-options.ts's batch filter andfieldControlKind. A type whose option SQL ran can never be one the bar refuses a select for.fieldControlKindclassifies the TYPE ('multi');filter-bar.tspairs that withspec.options !== null, because only the bar can see whether option SQL was configured. AnArray(scalar T)with no options keeps its text field, with a marker that now names the fix rather than calling a controllable type uncontrollable.FilterBarApp.state.varValues. Widening that type would not have been a safeguard — TS property assignability is covariant even for mutable properties, so a widened type would still accept the realAppStatewhile letting an array through. Keeping itRecord<string, string>is the enforcement.[].emptyValue()treats a present[]as a real value, so binding one would run panels as… IN []— nothing returned, but looking filtered. Narrows Dashboard query-backed filters: searchable multiselect with Apply #189, which could express an "active empty array".applyOptionsreturns the names whose bound set actually changed (a pure reorder reports nothing);refreshruns a singlecommitAndRerunover the union after both the option request and tile pool settle — re-running insiderunOptionBatchwould supersede tiles mid-refresh.Latent bug fixed
dashboard-viewer-session.tsmarked everyArray(T)variable with valid option SQLstatus: 'error'through a branch commented "Unreachable viaoptionBatchVariables' own rule". It was reachable and wrong — invisible only because the select it applied to never rendered.Verification
npm test— 5582 tests, 174 files, 100% statements + lines, no per-file threshold violation.npx playwright test— 366 tests pass across Chromium, Firefox and WebKit, including 5 new multi-select specs (Tab traversal through the real control sequence, Escape-as-Cancel focus return, search-hidden rows skipped by Tab, and the shared#386Apply action states in both themes).Live against
github.demo.altinity.cloud: 13 options load from your query; search narrows to 5; Select visible goes indeterminate → checked with correctly scoped labels; Apply bindsa real ClickHouse literal, not a joined string. Panels re-ran with no errors, and the selection survived a reload from
asb:dashFiltersas a JSON array.Forward-work reconciliation
docs/ADR-0003— new Restore the searchable multi-select for Array(scalar T) Dashboard variables with option SQL #468 addendum, and the phase-2 addendum's compound-type justification narrowed.CHANGELOG.md[Unreleased]— new entry, plus the existing Replace curated Dashboard filters with inferred Variables and batched option queries #447 bullet narrowed.README.md— the Dashboard variables section.🤖 Generated with Claude Code
https://claude.ai/code/session_01BF6uJrfy51KgkeTvfzyWDB