feat(#189): searchable multiselect for query-backed Dashboard filters with Apply - #364
Merged
Conversation
…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>
…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
7 tasks
…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
7 tasks
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>
This was referenced Jul 26, 2026
Closed
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
DashboardFilterDefinitionV1gains an optionalselection.mode("single" | "multiple") override (closed sub-object, regenerated types/validators). The new purecore/filter-selection.tsresolves each source-backed filter's helper contract from its executable consumers — defined once ingatherExecutableConsumers: the resolved executable target tiles (explicittargetselse 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: scalarT→ single,Array(T)→ multiselect; explicitmultipleon 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-precisefilter-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 invalidateDashboardSemantics, 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 existingArray(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 anArray(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:
a7dc5ff— selection contracts are now validated invalidateDashboardSemanticswith exact JSON paths (was runtime-only), and "executable consumer" is defined once (gatherExecutableConsumers;resolveFilterSelectiondropped itsdependentSourcesinput — a Filter source can never be an executable consumer).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 validArray(String)helper survives a conflicting declaration in a presentation-error tile and in a cascading-invalid source.9d552b5—toParamValuepreserves every array including[](active[]serializes as'[]'); default activation is array-aware; activation comes exclusively from the active map.b37feb2— Apply closes the popover beforeonApply, and the "Filter options were refreshed" announcement fires only when the open parameter'soptionsRevactually changed; focus lands on the fresh trigger after an Apply-triggered rebuild.Review notes / deliberate decisions
filter-option-field, Dashboard: multi-filter option bundles, shared preview, and role-aware result selector #160) has no free-text commit path — enabling it on error produced a field that looked editable but reverted everything typed, so the review reverted to disabled-on-error. If the strict single-select should also gain a free-text failure fallback, that's a small follow-up decision on Dashboard: multi-filter option bundles, shared preview, and role-aware result selector #160's strictness contract.selection/selection.mode/single/multiplecomes for free from the schema-driven engine; there is no dashboard-JSON editor surface yet, so a schema-service conformance test pins the suggestions for the day one bindsdashboardV1Schema.refresh()may not re-execute a tile whose committed value is deactivated by newly-arrived options within the same wave in some fixture shape — flagged here rather than filed, since we could not reduce it to a confirmed product bug.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 testpasses (159 files / 4648 tests; per-file coverage gate green)npm run buildsucceeds (single-filedist/sql.html)src/core/(filter-selection.ts100%), DOM insrc/ui/behind the app controller; no network changesCHANGELOG.md([Unreleased]) updated3840d57earlier the same day, but is currently environmentally degraded on this build host — every failure is apage.goto/context-teardown timeout inbeforeEachunder 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