Skip to content

feat(#189): searchable multiselect for query-backed Dashboard filters with Apply - #364

Merged
BorisTyshkevich merged 11 commits into
mainfrom
feat/filter-multiselect-189
Jul 22, 2026
Merged

feat(#189): searchable multiselect for query-backed Dashboard filters with Apply#364
BorisTyshkevich merged 11 commits into
mainfrom
feat/filter-multiselect-189

Conversation

@BorisTyshkevich

@BorisTyshkevich BorisTyshkevich commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

What & why

Closes #189 — searchable multiselect UX for query-backed Dashboard filters whose consumers use a ClickHouse Array(T) parameter, with local draft state and an explicit Apply.

Schema & core. DashboardFilterDefinitionV1 gains an optional selection.mode ("single" | "multiple") override (closed sub-object, regenerated types/validators). The new pure core/filter-selection.ts resolves each source-backed filter's helper contract from its executable consumers — defined once in gatherExecutableConsumers: the resolved executable target tiles (explicit targets else declaring tiles; Filter sources are never consumers, since under #360's single-layer rule a source depending on a source-backed parameter can never execute) — and derives the effective mode per the issue's table (omitted mode: scalar T → single, Array(T) → multiselect; explicit multiple on scalar is diagnosed, never silently downgraded). Any unresolved contract (mixed arity, type/element conflicts, nested arrays, undeclared/non-executable targets, target-less configs, unknown modes) falls back to the ordinary string input with persistent, path-precise filter-selection-* diagnostics; a source left with zero consumers never executes; declarations outside the resolved consumer set (a presentation-error tile, a cascading-invalid source, a non-targeted tile) can never suppress a valid helper — the per-wave merge consumes controls derived from the same resolved contracts. The same resolver runs at authoring/import time in validateDashboardSemantics, mapping each diagnostic to its exact document path (filters[i].selection.mode, filters[i].targets[j], filters[i].parameter), so an invalid dashboard is caught at whole-workspace validation, not first in the viewer. Inference is runtime-only — nothing is ever written back into the dashboard document.

Runtime value model. Committed multiselect values are real string[] end to end: viewer state, the typed pipeline (the existing Array(T) serializer — new element coverage for Unicode, empty strings, commas, Decimal, Enum labels, DateTime), structural equality (sameSelection), and localStorage persistence (DashboardFilterEntry.value: string | string[]). Empty-string elements are valid; duplicates are removed; an inactive filter retains its dormant array; every array — including [] — passes through untouched (an active empty array serializes as a real []), with activation decided exclusively by the active flag and array-aware default-activation inference (defaultValue: [] starts inactive).

Execution planning. Filter commits now plan the affected-panel wave from each parameter's resolved targets instead of rerunning every tile that merely declares the name (one shared resolver with the #235 wave-deferral gate, which is now computed post-fallback). One commit → at most one wave; a no-op Apply issues nothing.

Option-refresh reconciliation (array-aware, by bound value): surviving selections stay active in canonical option order; label/order-only refreshes rerun nothing; removals join the single reconciled wave; an empty intersection deactivates keeping the dormant value; new options are never auto-selected. A refresh landing while the popover is open cancels it as a draft-discard and announces "Filter options were refreshed" via a persistent live region.

UI. New dedicated ui/multi-select-field.ts (per the issue: not the single-select combobox forced into multiselect ARIA): closed trigger (All / Not set / selected label / N selected / loading / waiting), role="dialog" popover with labeled search, tri-state Select visible scoped to the filtered subset, checkbox options, Clear / Cancel / Apply, a Tab focus trap, focus return to the trigger on every dismissal, and an announced noninteractive busy body while options load mid-open. Error statuses swap in an enabled free-text input (raw values still flow through the typed pipeline via the serializer's scalar passthrough). selection.mode: "single" on an Array(T) contract keeps the strict single-select and commits [value] / clears to [], false.

Merge-gate review round (2026-07-22)

All four maintainer findings fixed as commits on this PR:

  1. a7dc5ff — selection contracts are now validated in validateDashboardSemantics with exact JSON paths (was runtime-only), and "executable consumer" is defined once (gatherExecutableConsumers; resolveFilterSelection dropped its dependentSources input — a Filter source can never be an executable consumer).
  2. 9d552b5 — the over-broad dashboard-wide conflict gate is removed; the per-wave merge consumes resolved-consumer-derived controls, so non-executable/non-targeted declarations can't suppress a valid helper. Regression: a valid Array(String) helper survives a conflicting declaration in a presentation-error tile and in a cascading-invalid source.
  3. 9d552b5toParamValue preserves every array including [] (active [] serializes as '[]'); default activation is array-aware; activation comes exclusively from the active map.
  4. b37feb2 — Apply closes the popover before onApply, and the "Filter options were refreshed" announcement fires only when the open parameter's optionsRev actually changed; focus lands on the fresh trigger after an Apply-triggered rebuild.

Review notes / deliberate decisions

Process: coordinator + sequential file-scoped sonnet workers; three independent read-only review passes (issue conformance, session correctness, UI/a11y) with all majors fixed in 3840d57.

Checklist

  • npm test passes (159 files / 4648 tests; per-file coverage gate green)
  • Tests added/updated in the same change as the code (+153 tests)
  • npm run build succeeds (single-file dist/sql.html)
  • Layers kept honest: pure logic in src/core/ (filter-selection.ts 100%), DOM in src/ui/ behind the app controller; no network changes
  • No new runtime dependency
  • CHANGELOG.md ([Unreleased]) updated
  • Reconciled affected tracked work (roadmap Roadmap to 1.0.0 #68 checked off)
  • e2e: chromium + webkit fully green on the final code (incl. the merge-gate fixes). Firefox was fully green (231/231 all engines) on 3840d57 earlier the same day, but is currently environmentally degraded on this build host — every failure is a page.goto/context-teardown timeout in beforeEach under load (loadavg 5–7.5, shared box), never an assertion or app code. Known host-specific Firefox instability; chromium+webkit are the real signal here.

🤖 Generated with Claude Code

BorisTyshkevich and others added 6 commits July 21, 2026 19:52
…store, Array(T) serializer coverage

Wave 1 of the multiselect track: DashboardFilterDefinitionV1 gains an
optional selection.mode (single|multiple) override; the persisted
dashboard-filter bag round-trips string[] values without stringification;
param-serialize gains the issue's required Array(T) element coverage
(Unicode, empty string, commas, Decimal, Enum labels, DateTime).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… helpers

resolveFilterSelection derives the curated helper's effective single/multiple
mode from the agreed consumer type across resolved targets and dependent
Filter sources, failing closed with path-precise filter-selection-* diagnostics
(mixed arity, type/element conflicts, nested arrays, multiple-on-scalar,
unknown modes, undeclared/non-executable targets). sameSelection/
canonicalizeSelection/reconcileSelection are the pure value helpers for the
multiselect Apply and option-refresh reconciliation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rays, plans waves by resolved targets

Source-backed filters resolve their multiselect contract once at
construction (resolveFilterSelection): any resolution diagnostic falls the
filter back to the plain string input with persistent path-precise
diagnostics and disconnects it from its source (a zero-consumer source
never executes). Committed string[] values now reach the typed pipeline
un-stringified (empty array reads as missing; defensive copies at every
store/commit seam). runAffectedWave consults each parameter's resolved
targets (explicit def.targets else declaring tiles, one shared resolver
with the #235 wave gate) instead of rerunning every tile declaring the
name. Option-refresh reconciliation is array-aware via reconcileSelection:
canonical reorders don't wave, narrowed selections stay active and join
the single reconciled wave, an empty intersection deactivates keeping the
dormant array.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
buildMultiSelectField: trigger (All/Not set/label/N selected/loading/
waiting states) + role=dialog popover with labeled search, tri-state
Select-visible scoped to the filtered subset, checkbox options, Clear/
Cancel/Apply. Draft Set stays local until Apply, which canonicalizes via
core filter-selection helpers and commits at most once (no-op closes
silently); every dismissal path discards the draft and returns focus to
the trigger. Error statuses swap in an enabled plain text input per
#189's failure-fallback rule instead of a bricked disabled control.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
buildFilterBar picks the curated control from the published selection
contract: multiple → buildMultiSelectField (Apply commits arrays through
the new onApplyCurated seam), single-on-Array wraps the single-select
pick/clear into [value]/[] commits, no contract → pre-#189 behavior.
Error statuses leave the curated input enabled (usable free-text
fallback) per #189 instead of bricking it. dashboard.ts persists real
arrays, JSON-encodes them in the rebuild signature, and announces
'Filter options were refreshed' through a persistent live region when a
rebuild cancels an open multiselect popover.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…us handling, focus trap, loading affordance

Session: a dashboard-wide parameter type conflict now falls the filter back
at construction (matching the merge layer's field-level rejection — no
published-contract/dead-field hybrid), and the #235 wave-deferral gate is
computed from the post-resolution state so a fallen-back filter defers
nothing. UI: a raw-string fallback commit stays visible on the trigger and
error input instead of vanishing; forced popover closes move focus to the
swapped-in error input (or the rebuilt bar's trigger) instead of dropping
it to body; the aria-modal dialog gets a real Tab focus trap; error-mode
edit state resets on re-entry and can't force-commit on programmatic
removal; a status-only loading transition while the popover is open now
disables the checklist with an announced 'Loading options…' busy state.
The strict single-select restores #360's disabled-on-error affordance
(its enabled variant was a dishonest affordance — #189's string-input
failure fallback lives in the multiselect control, which has a real
free-text path). Adds the dashboard-level refresh-cancel integration test
and the #189 authoring-completion schema conformance test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@BorisTyshkevich BorisTyshkevich mentioned this pull request Jul 21, 2026
93 tasks
BorisTyshkevich and others added 3 commits July 22, 2026 04:54
…d at whole-workspace semantics (merge-gate review)

'Executable consumer' is now defined once (gatherExecutableConsumers):
a filter's resolved executable target tiles, nothing else — Filter
sources can never be consumers under #360's single-layer cascading rule,
so resolveFilterSelection drops its dependentSources input.
validateDashboardSemantics now runs the same resolver at authoring/import
time, mapping diagnostics to exact JSON paths (filters[i].selection.mode,
filters[i].targets[j], filters[i].parameter) so an invalid dashboard is
caught at whole-workspace validation instead of only degrading in the
viewer; the resolver's bound-aware checks subsume the older per-target
undeclared/type-conflict checks for source-backed filters.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rvive to the pipeline (merge-gate review)

The over-broad dashboard-wide conflict gate is gone: a declaration outside
a filter's resolved executable-consumer set (a presentation-error tile, a
never-executing cascading-invalid source, a non-targeted tile) can no
longer suppress a valid helper. The per-wave merge now receives, for each
curated parameter, a control derived from the resolved contract (value
type, no dashboard-wide conflict) — one consumer definition across
resolution, merge, and static semantics. toParamValue passes every array
through (including []), so value=[], active=true serializes as a real
empty Array(T) '[]' instead of collapsing to a blank scalar; default
activation inference is array-aware ([] → inactive) — activation is
decided exclusively by the active flag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cement gated on a real options change (merge-gate review)

A normal Apply no longer routes through the stale-draft cancellation
path: the popover tears down before onApply fires, so applyFilter's
synchronous publish/rebuild can never see it open. The 'Filter options
were refreshed' announcement now requires the open parameter's optionsRev
to have actually changed since the retained bar was built — a value/
active commit rebuild announces nothing. Focus restoration keys off the
outgoing bar's open-or-focused multiselect parameter, so an Apply lands
focus on the fresh trigger without stealing focus from other fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…into #189 multiselect

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDsUDSPoDYa1M1rbpgdG3f
…ashboard filter (#364)

Favoriting a `filter`-role saved query now attaches its option list to an
implicit panel-tile parameter of the same name — the field upgrades from a
plain text box to the query-backed control with no authored filter definition
and no per-filter settings (single vs. multiselect stays inferred from the
consumer type). synthesizeImplicitFilters sets `sourceQueryId` by pure
name-matching against the source's parsed top-level output columns (new pure
`core/select-columns.ts`, 100% covered): a parameter produced by exactly one
favorited source binds; zero or ambiguous (>=2) stays plain. Runtime-only,
never persisted.

Completes the consumption pipeline from #189/#360 (which resolved and rendered
source-backed filters but had no wiring to create the binding).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDsUDSPoDYa1M1rbpgdG3f
@BorisTyshkevich
BorisTyshkevich merged commit 4d54194 into main Jul 22, 2026
9 checks passed
BorisTyshkevich added a commit that referenced this pull request Jul 22, 2026
…To picker in the filter bar (#376)

* feat(#335): pure time-range core — group resolver, draft validation, absolute-instant parser, recents

- src/core/time-range.ts: inferTimeRangePairs (#334 interim name-pair table,
  curated filters excluded, ambiguity drops), resolveTimeRangeGroups gated on
  scalar date-like consumer contracts via resolveFilterSelection,
  validateTimeRangeDraft (one shared preview now, from<=to at resolved
  instants, equal permitted), pushRecentRange (dedupe, cap 6)
- src/core/relative-time.ts: parseAbsoluteInstant (strict, generous syntax:
  preview formats, ISO-T, epoch digits for DateTime; UTC convention);
  formatPreviewInstant exported for reuse
- src/ui/relative-time-field.ts: TIME_RANGE_CONSTANTS (design's 14 tokens),
  filterTokenList generalization; RELATIVE_TIME_PRESETS bit-identical

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(#335): session batch commit, time-range group exposure, one wall-clock snapshot per wave

- applyFilters(entries): atomic multi-filter commit — unknown/duplicate id or
  zero-change call is a whole-call no-op; one publish, one commitAndRerun over
  the changed parameters (single wave over the union of resolved targets)
- readonly timeRangeGroups: resolved once at construction via
  core/time-range.ts, after #189 source-fallback resolution (curated excluded)
- DashboardViewState.waveWallNowMs: each wave entry point captures one
  deps.wallNow() and threads it through prepareBatch/filter-source waves —
  fixes the latent bug where one refresh resolved relative tokens against
  multiple instants; getFilterField keystroke validation keeps live wall-now

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(#335): extract shared anchored-dialog popover primitive from the multiselect

- src/ui/popover.ts: openAnchoredDialog owns the generic dialog chrome
  (overlay/backdrop, role/aria-modal, Escape, Tab trap with per-press
  recompute, fixedAnchor placement, aria-expanded, teardown + focus return
  with skipFocus); documents the close-before-commit rule (#364)
- multi-select-field.ts migrated behavior-preserving; its spec untouched
  byte-for-byte as the regression proof; busy state/live region stay content
- dom.ts fixedAnchor: optional pure right-edge clamp (panelW + viewportW)
  for the upcoming time-range popover; existing callers unchanged

Second consumer lands in the next commit (CLAUDE.md rule 5 threshold).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(#335): compound time-range control — trigger + staged From/To popover

- src/ui/time-range-field.ts: dialog-pattern trigger (resolved range label,
  Not set, error states; aria-label carries tokens + resolved range;
  refreshLabel(nowMs) for per-wave re-resolution), popover via the shared
  openAnchoredDialog primitive (second consumer): staged From/To editors with
  one shared preview now, per-field constants column with typing filter,
  group recents with immediate apply, Cancel/Apply footer gated by
  validateTimeRangeDraft, identical-draft Apply closes without commit
- .trf-* styles on existing theme tokens (light+dark)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(#335): wire the time-range control into the filter bar and dashboard

- filter-bar.ts: Time section (one control per group, grouped params
  suppressed) ahead of the remaining filters; the two parallel handle maps
  unified into one FieldHandle map with opaque keys (param | group:key);
  multiselect-specific hooks generalized (openPopoverKey/focusedFieldKey/
  focusFieldTrigger); refreshTimeRangeLabels(nowMs)
- dashboard.ts: timeRange assembly from session.timeRangeGroups + published
  filter state; shell-owned session-scoped recents (outgoing pair, no-op and
  first-commit excluded); onApplyTimeRange -> session.applyFilters (both
  bounds, one wave) + polite live-region announcement; per-wave trigger label
  re-resolution off waveWallNowMs without a bar rebuild; read-only mode uses
  the same path

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(#335): review-round fixes — focus return, failure announcements, refreshTile wave snapshot, year floor, NUL hygiene

- time-range-field: a constant pick re-focuses the field input (the click
  detaches the picked button); polite sr-only region announces validation
  failures (per-field + range), deduped, cleared on valid
- dashboard-viewer-session: refreshTile is a wave of one — publishes its own
  waveWallNowMs and binds the tile against it (was an untethered wallNow())
- relative-time: calendar years below 1900 rejected (no ClickHouse date type
  reaches lower; keeps Date.UTC's 0-99 remap unreachable)
- time-range-field.test.ts: literal NUL byte replaced with the \u0000 escape
  (file was binary-classified by git); stale hook name in a multiselect comment

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(#335): real-browser e2e for the time-range control + CHANGELOG

- tests/e2e/time-range.html/.spec.js: real viewer session + real buildFilterBar
  through the dashboard glue — group resolution, pair replacement, staged
  editing, disabled-Apply gating, exactly-one-wave batch commit, recents
  immediate apply, Escape/backdrop/focus-return, light/dark, 360px clamping
  (chromium+webkit: 38 passed)
- dashboard-mobile fixture: compound control in the narrow-bar layout checks
- CHANGELOG [Unreleased]: #335 feature entry + the wave-snapshot fix

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(#335): Apply on an inactive pair with unchanged values commits — activation is the change (merge-gate review)

The identical-draft no-op compared only the two text values; with a
committed-but-inactive pair (clearFilter keeps the typed value and only
flips active off, and defaultValue+defaultActive:false is authorable) the
popover seeded valid text and Apply silently closed without committing,
leaving no UI path to activate the range. The no-op now additionally
requires the pair to already be active; the session's own changed-set
comparison treats the active flip as a real change and runs one wave.
Recents stay clean: the outgoing pair is only pushed when it was active.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
lesandie pushed a commit to lesandie/altinity-sql-browser that referenced this pull request Aug 2, 2026
…lar T) variables

`Array(T)` is the type a multi-select exists for, but Altinity#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 Altinity#189/PR-Altinity#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 Altinity#447's non-goals/control matrix/phase-1 gate, ADR-0003's phase-2
addendum, CHANGELOG, README and roadmap Altinity#68.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF6uJrfy51KgkeTvfzyWDB
@BorisTyshkevich
BorisTyshkevich deleted the feat/filter-multiselect-189 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.

Dashboard query-backed filters: searchable multiselect with Apply

1 participant