Skip to content

feat(#468): restore the searchable multi-select for Array(scalar T) Dashboard variables - #469

Merged
BorisTyshkevich merged 4 commits into
mainfrom
feat/restore-array-multiselect
Jul 26, 2026
Merged

feat(#468): restore the searchable multi-select for Array(scalar T) Dashboard variables#469
BorisTyshkevich merged 4 commits into
mainfrom
feat/restore-array-multiselect

Conversation

@BorisTyshkevich

Copy link
Copy Markdown
Collaborator

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 own Variable: user tab — 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.ts has always turned a JS string[] into a ClickHouse literal, and dashboard-filter-store.ts survived #447 with its string | 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

  • One predicate. multiSelectElementType (core/param-type.ts) is read by both variable-options.ts's batch filter and fieldControlKind. A type whose option SQL ran can never be one the bar refuses a select for.
  • fieldControlKind classifies the TYPE ('multi'); filter-bar.ts pairs that with spec.options !== null, because only the bar can see whether option SQL was configured. An Array(scalar T) with no options keeps its text field, with a marker that now names the fix rather than calling a controllable type uncontrollable.
  • The string boundary is the type, not a comment. Arrays travel session → spec → control → callback and never enter 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 real AppState while letting an array through. Keeping it Record<string, string> is the enforcement.
  • Empty selection is unset, not []. 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".
  • One wave. applyOptions returns the names whose bound set actually changed (a pure reorder reports nothing); refresh runs a single commitAndRerun over the union after both the option request and tile pool settle — re-running inside runOptionBatch would supersede tiles mid-refresh.

Latent bug fixed

dashboard-viewer-session.ts marked every Array(T) variable with valid option SQL status: 'error' through a branch commented "Unreachable via optionBatchVariables' own rule". It was reachable and wrong — invisible only because the select it applied to never rendered.

Verification

  • npm test5582 tests, 174 files, 100% statements + lines, no per-file threshold violation.

  • npx playwright test366 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 #386 Apply 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 binds

    param_user=['btyshkevich@altinity.com','mfilimonov@altinity.com','altinity','dholmes@altinity.com','dborovtsov@altinity.com']
    

    a real ClickHouse literal, not a joined string. Panels re-ran with no errors, and the selection survived a reload from asb:dashFilters as a JSON array.

Forward-work reconciliation

🤖 Generated with Claude Code

https://claude.ai/code/session_01BF6uJrfy51KgkeTvfzyWDB

BorisTyshkevich and others added 3 commits July 26, 2026 18:20
…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
@BorisTyshkevich

Copy link
Copy Markdown
Collaborator Author

Review findings verified and fixed — all three were real

I reproduced each with a throwaway probe before touching anything:

# Reproduction Verdict
1 Control built with selected:['ada','bo'], active:true, options:[] → trigger reads 2 selected, popover opens, no-change Apply fires onApply([], false) Confirmed — silent data loss
2 reconcileSelection(['ada','bo'], [bo,ada]){value:['bo','ada'], waveNeeded:false} Confirmed
3 1001 rows → truncated:['user'], reconcileSelection(['u5000'], options){value:[], deactivate:true, waveNeeded:true} Confirmed — silent data loss

Finding 1's premise also checks out: await session.start() is the last statement of renderDashboard (dashboard.ts:2426), so the control is interactive for the entire option request — the window is the whole query, not a paint frame.

Fixed in 249f7a0, docs in 65677eb.

1 — loading guard

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 dropped it along with the Filter-source cascade states it shipped beside, which genuinely have no meaning here.

Kept aria-disabled rather than disabled so the reason in title stays reachable and focus never drops to <body>.

2 — order-preserving reconciliation

reconcileSelection now only filters; it never reorders. You're right that {name:Array(T)} promises nothing about membership semantics — panel SQL may read the array positionally — so adopting a new option order changed what panels bind while reporting no wave, and persisted the difference. The user's own Apply still canonicalizes, which is correct: that's a deliberate action against a list they're looking at. Net fewer lines.

3 — no pruning against an incomplete list

Reconciliation 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 LIMIT, which is what actually says the server cut the result off.

Tests

  • Requested delayed-option-request test added at the shell level (dashboard.test.ts): a deferred option responder + a persisted ['ada','bo'], asserting the trigger is inert mid-flight, the click is refused, and the selection is intact afterwards. Plus 5 control-level loading cases, a bar-level case, and session cases for order-preservation and the capped list.
  • 5591 tests, 100% statements + lines; 15 multi-select e2e across Chromium/Firefox/WebKit.

Live re-verification

Seeded a 5-value selection and had an init script attempt the destructive click during the loading window:

transitions:            ["Loading options… | disabled=true busy=true",
                         "5 selected | disabled=false busy=false"]
attemptedDuringLoading: true
popoverOpenedDuringLoading: false
stored after:           {"value":["default","demo","altinity","updater","dmitrii"],"active":true}
param_user:             ['default','demo','altinity','updater','dmitrii']

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
@BorisTyshkevich

Copy link
Copy Markdown
Collaborator Author

Confirmed and fixed — my previous fix was incomplete

You're right, and this was a real gap in my own fix rather than a new issue. Reproduced first:

trigger:              u5000                 ← correct, shown verbatim
no-change Apply:      onApply([], false)    ← clears
pick one visible:     onApply(['u1'], true) ← u5000 silently gone

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 c7d41e7.

What changed

optionsTruncated is now published on ViewerFilterState and threaded through VariableFieldSpec and setVariableOptions down to the control. Apply keeps draft values the list does not offer — appended in committed order — when and only when the list is a prefix.

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.

  • Clear still removes them — it empties the whole draft, which is the explicit "remove everything". That's your "unless the user explicitly uses Clear".
  • 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. Preserving there would resurrect it.

I also added optionsTruncated to the options signature in dashboard.ts — it changes how the control commits, so a flip has to reach it even in the contrived case where the accompanying option content is byte-identical.

Tests

Your requested case is in at the shell level (dashboard.test.ts): a real 1,001-row option response through renderDashboard, an off-list persisted value, no-change Apply — asserting the trigger still reads the raw value, no panel re-ran, and param_user is unchanged.

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, setOptions toggling the flag both ways, and the default.

5601 tests, 100% statements + lines; 15 multi-select e2e across three engines.

One honest gap

I 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 user list is 13 rows, and forcing >1,000 would mean editing your dashboard's option SQL. The 1,001-row path is covered through the real renderDashboard + session + control stack in the shell test, which is the same code path minus real layout.

@BorisTyshkevich
BorisTyshkevich merged commit 008da32 into main Jul 26, 2026
6 checks passed
@BorisTyshkevich
BorisTyshkevich deleted the feat/restore-array-multiselect branch August 6, 2026 15:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Restore the searchable multi-select for Array(scalar T) Dashboard variables with option SQL

1 participant