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: 58 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,52 @@ auto-generated per-PR notes; this file is the curated, human-readable history.
## [Unreleased]

### Added
- **Searchable multiselect for query-backed Dashboard filters** (#189). A
source-backed filter whose executable consumers agree on one `Array(T)`
parameter type now renders a dedicated searchable-checklist control: the
closed trigger shows `All` / `Not set` / the selected label / `N selected`,
and the popover offers a labeled search, a tri-state **Select visible**
scoped to the filtered subset, per-option checkboxes, and **Clear / Cancel /
Apply** — edits stay in a local draft until Apply, which canonicalizes by
option order, commits at most once, and triggers at most one targeted
panel wave (a no-op Apply issues nothing; Cancel/Escape/outside-click
discard the draft and return focus to the trigger). The effective mode is
inferred at runtime from the agreed consumer type (scalar `T` → single,
`Array(T)` → multiselect) and can be overridden per filter with the new
optional `DashboardFilterDefinitionV1.selection.mode` (`"single"` on an
array contract commits `[value]`); inference is runtime-only and never
written back into the dashboard document. A helper is exposed only when
every executable consumer (the filter's resolved targets plus any dependent
Filter sources) agrees on one compatible type — conflicting scalar/Array or
element types, nested arrays, undeclared targets, target-less
configurations, an explicit `multiple` on a scalar contract, or an unknown
mode all fall back to the ordinary string input with persistent
path-precise `filter-selection-*` diagnostics (never a silent downgrade).
"Executable consumer" is defined once (`gatherExecutableConsumers`) and
shared by contract resolution, the per-wave helper merge, and
whole-workspace semantic validation — `validateDashboardSemantics` runs
the same resolver at authoring/import time, mapping each diagnostic to
its exact document path (`filters[i].selection.mode`,
`filters[i].targets[j]`, `filters[i].parameter`), and a declaration
outside the resolved consumer set (a presentation-error tile, a
never-executing cascading-invalid source, a non-targeted tile) can never
suppress a valid helper.
Committed multiselect values stay real `string[]` arrays end to end —
through viewer state, localStorage persistence, structural equality, and
the existing typed `Array(T)` serializer (duplicates removed, empty-string
elements valid, never comma-joined; an active empty array serializes as a
real `[]` — activation is decided exclusively by the active flag, never by
a value sentinel). Option refreshes reconcile by bound
value: surviving selections stay active in canonical order (label/order-only
changes rerun nothing), removals join one reconciled panel wave, an empty
intersection deactivates the filter keeping its dormant value, and new
options are never auto-selected; a refresh that lands while the popover is
open cancels it (announced via a live region) so a stale draft can never be
applied. Filter commits now plan their panel wave from the filter's
**resolved targets** (explicit `targets` else declaring tiles) instead of
rerunning every tile that merely declares the parameter name, and a failed
source degrades the curated control to a usable free-text input (raw values
still flow through the typed pipeline) instead of a disabled one.
- **Parameterized Dashboard Filter sources with single-layer dependencies**
(#360). A Filter-role source query may now declare its own `{name:Type}`
query parameters and bind committed *root* Dashboard filter values through the
Expand Down Expand Up @@ -37,6 +83,18 @@ auto-generated per-PR notes; this file is the curated, human-readable history.
`examples/query-log-explorer.json`'s `qle-filter` source is migrated to a
`{from:DateTime}` / optional `{to:DateTime}` window (matching its panels, where
`to` is optional and means "up to now") as a worked example.
- **Favoriting a Filter-source query auto-binds it to a matching Dashboard
filter** (#189/#364). A favorited `filter`-role saved query whose top-level
output column name equals an (otherwise implicit) panel-tile parameter now
attaches its option list to that parameter automatically — the field upgrades
from a plain text box to the query-backed control with no authored
`DashboardFilterDefinitionV1` and no per-filter settings (single vs.
multiselect is still inferred from the consumer type). Binding is pure
name-matching against the source's parsed output columns
(`core/select-columns.ts`): a parameter produced by exactly one favorited
source binds; a parameter produced by zero or by two-or-more (ambiguous)
favorited sources stays a plain input rather than guessing. Runtime-only —
the synthesized binding is never written back into the dashboard document.

### Fixed
- **Saved-query, workspace, and Dashboard persistence stay consistent** (#365).
Expand Down
15 changes: 14 additions & 1 deletion schemas/dashboard-v1.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -169,10 +169,23 @@
"title": "Active by default",
"description": "Whether the filter starts active.",
"type": "boolean"
},
"selection": {
"title": "Selection mode override",
"description": "Optional explicit selection-mode override for searchable multiselect filters (#189). Omitted means the runtime infers the mode from the agreed consumer parameter type across target queries: a scalar T infers single selection, an Array(T) infers multiselect. Inference is runtime-only and is never persisted here.",
"type": "object",
"properties": {
"mode": {
"title": "Selection mode",
"description": "Explicit override for the inferred selection mode: \"single\" forces one active value, \"multiple\" forces a searchable multiselect.",
"enum": ["single", "multiple"]
}
},
"additionalProperties": false
}
},
"additionalProperties": false,
"x-altinity-order": ["id", "parameter", "label", "sourceQueryId", "targets", "defaultValue", "defaultActive"]
"x-altinity-order": ["id", "parameter", "label", "sourceQueryId", "targets", "defaultValue", "defaultActive", "selection"]
},
"dashboardLayoutFallbackV1": {
"title": "Layout fallback",
Expand Down
19 changes: 18 additions & 1 deletion schemas/generated/library-v2.bundle.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1600,6 +1600,22 @@
"title": "Active by default",
"description": "Whether the filter starts active.",
"type": "boolean"
},
"selection": {
"title": "Selection mode override",
"description": "Optional explicit selection-mode override for searchable multiselect filters (#189). Omitted means the runtime infers the mode from the agreed consumer parameter type across target queries: a scalar T infers single selection, an Array(T) infers multiselect. Inference is runtime-only and is never persisted here.",
"type": "object",
"properties": {
"mode": {
"title": "Selection mode",
"description": "Explicit override for the inferred selection mode: \"single\" forces one active value, \"multiple\" forces a searchable multiselect.",
"enum": [
"single",
"multiple"
]
}
},
"additionalProperties": false
}
},
"additionalProperties": false,
Expand All @@ -1610,7 +1626,8 @@
"sourceQueryId",
"targets",
"defaultValue",
"defaultActive"
"defaultActive",
"selection"
]
},
"dashboardLayoutFallbackV1": {
Expand Down
29 changes: 28 additions & 1 deletion src/core/dashboard-filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { ParsedParamType } from './param-type.js';
import { diagnostic } from './diagnostics.js';
import type { Diagnostic } from './diagnostics.js';
import type { FieldControl } from './param-pipeline.js';
import { reconcileSelection } from './filter-selection.js';

// `param-serialize.js` is unconverted (checkJs:false) — the same narrow
// result contract `param-pipeline.ts` declares for the same function.
Expand Down Expand Up @@ -146,7 +147,33 @@ export function mergeDashboardFilterHelpers({
const changed: string[] = [];
for (const [name, field] of Object.entries(fields)) {
if (!active[name]) continue;
if (field.options.some((option) => option.value === String(values[name] ?? ''))) continue;
const committed = values[name];
if (Array.isArray(committed)) {
// #189: a multiselect (Array-contract) filter's committed value is a
// real string array — it must NEVER go through the scalar `String(...)`
// comparison below (which would stringify `['a','b']` as `"a,b"` and
// never match any option). `reconcileSelection` (filter-selection.ts)
// is the same pure decision core the multiselect control itself uses
// for this exact refresh.
const reconciled = reconcileSelection(committed as string[], field.options);
if (reconciled.deactivate) {
// Every previously-selected value is gone: deactivate, but KEEP the
// dormant array untouched (`nextValues[name]` stays the ORIGINAL
// committed array) — matches the scalar path's own reactivation
// policy (a cleared filter keeps its retained value).
nextActive[name] = false;
changed.push(name);
} else {
// Non-empty intersection: the value updates to the canonical (fresh
// option order) survivors — even a pure reorder/label-only refresh
// (`waveNeeded: false`) still updates the value, it just isn't a
// change a caller needs to re-run anything for.
nextValues[name] = reconciled.value;
if (reconciled.waveNeeded) changed.push(name);
}
continue;
}
if (field.options.some((option) => option.value === String(committed ?? ''))) continue;
nextActive[name] = false;
changed.push(name);
}
Expand Down
Loading
Loading