You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Add a global filter bar to the Dashboard: one text input per {name:Type} parameter
detected across every favorited tile's SQL, sharing the same persisted state.varValues the
SQL Browser workbench already uses (#134). Changing a value re-runs only the tiles that
reference it. No new parameter syntax, no schema change — this phase is "make the existing {name:Type} mechanism work across a set of queries instead of one."
Phase D3 of the Dashboard epic (#149 §6, "Global filters"). Builds directly on D1 (#150,
merged — read-only tiles via app.runTile) and depends on D2 (#151) merging first: it
extends the .dash-toolbar row D2 introduces (the Arrange/Report switcher), which the epic
explicitly designed as becoming the filter bar in this phase.
Current mechanism (reused, not reinvented)
The workbench already does everything this phase needs, just scoped to one query at a time —
see src/core/query-params.js (#134/#138) and src/ui/app.js's renderVarStrip/setRunBtn:
readStatementParams(sql) — the same, restricted to row-returning statements, unique by name.
unfilledParams(sql, values) / missingValues(params, values) — which detected names have no
(or empty) value yet in a { name: value } map.
paramArgs(stmt, values) — builds the param_<name> HTTP args ClickHouse substitutes
server-side; no-ops on non-row-returning SQL; omits a name whose value is empty/absent.
state.varValues (asb:varValues, plain persisted object, already shared across the
workbench and any dashboard tab since both load the same localStorage key) + app.saveVarValues().
This phase is entirely about fan-out: applying the same primitives across every favorite's
SQL instead of one tab's, and deciding what "run" means when there's no Run button.
Scope
src/core/dashboard.js — add:
// Union of every {name:Type} param referenced by any favorite's row-returning// SQL, unique by name, first-appearance order (favorite order, then in-SQL// order) — drives which fields the filter bar renders.exportfunctiondashboardParams(favorites){ ... }
Pure, imports readStatementParams from query-params.js. 100%-covered.
src/net/ch-client.js — thread parameter substitution through the dashboard tile request,
which today drops it entirely:
chUrl already accepts opts.params (used by the workbench's runQuery) — this just forwards
it through the two dashboard-specific wrappers. Backward compatible: existing 2-arg call sites
keep working (params defaults to undefined → no param_* args added).
src/ui/app.js — runTile(sql) keeps its signature but now substitutes, mirroring the
workbench's run():
(paramArgs/unfilledParams/readStatementParams are already imported in app.js for the
workbench path — no new import.) Gating (whether to call runTile at all) stays a UI-layer
decision in dashboard.js, matching how the workbench's Run-button gate lives in app.js's UI
code, not inside ch.runQuery.
src/ui/dashboard.js — the bulk of the work:
Filter bar. A .dash-filters group inside the existing .dash-toolbar (D2), built from dashboardParams(favorites): one <label class="var-field"> + <input class="var-input">
per param, reusing the workbench's existing CSS classes (var-field/var-name/var-input, src/styles.css:1094-1125) rather than duplicating them. Hidden entirely (no row, no
spacing) when dashboardParams(favorites).length === 0 — same convention as the workbench's var-strip (strip.style.display = 'none'). Values read from / write to app.state.varValues + app.saveVarValues(), so a value typed in the workbench and a value
typed on the dashboard are the same value.
Never on the keystroke path. Per the standing editor-variables rule, typing must not fire
a query per character. Each field debounces (~500 ms idle after the last keystroke — same
order of magnitude as the FROM-scope column-load debounce in codemirror-adapter.js, longer
because this fires a real query, not a metadata fetch) before triggering a re-run; Enter or
blur fires immediately, bypassing the debounce, for a fast explicit-commit path.
Affected-tile re-run. On a (debounced) filter change, recompute readStatementParams(q.sql)
per favorite and re-run only the favorites whose param list includes the changed name — not
the whole grid. A per-tile monotonically increasing generation counter guards against
out-of-order responses (edit A, then B, before A's request returns; B's response must win).
Per-tile unfilled-parameter gating. Before calling app.runTile(q.sql) (initial load or a filter-triggered re-run), compute unfilledParams(q.sql, app.state.varValues). If
non-empty, do not run the query — render a distinct placeholder state instead (e.g. "Enter
a value for: year" — same wording setRunBtn's tooltip uses), reusing the tile's card shell
(header/footer chrome) so it doesn't look broken. Unlike classifyTile's skip outcomes
(empty/kpi/non-chartable), an unfilled tile stays visible — it's excluded from the "N not
shown" tally, since a value away it becomes chartable.
Stable per-favorite tile slots (refactor). Today renderTile appends a new card per
favorite during the initial fan-out and card.remove()s it on skip — fine for a one-shot
build, but a filter change can flip a tile between skip ⇄ unfilled ⇄ chart repeatedly, and
removed/re-appended nodes would both reorder the grid and orphan the "same" tile's identity.
Refactor to: build one slot element per favorite up front, in favorite order, and have
every subsequent state transition (loading → chart / skip / unfilled / error, or a later
re-run) update that same slot's contents/visibility in place (display:none for skip,
visible content otherwise) rather than inserting/removing DOM nodes. This is what makes step 3
safe. liveTiles (the Chart.js-instance-per-tile teardown list) keyed by favorite index
instead of append order.
Live skip-note. The header's "N not shown" note currently is computed once at the end of
the initial fan-out. Recompute it from live per-slot status any time a slot's classification
changes (initial load or a filtered re-run), not just once.
src/styles.css — .dash-tile-unfilled (placeholder content style, echoing .dash-tile-error/.dash-tile-load); place .dash-filters inside .dash-toolbar's existing
flex-wrap row (no new sticky/scroll container needed — the toolbar already wraps at narrow
widths).
Tests — tests/unit/dashboard.test.js: dashboardParams pure cases (dedup across
favorites, first-appearance order, ignores non-row-returning SQL); filter bar renders one field
per detected param and stays hidden with none; typing debounces (fake timers) before the
affected tile(s) re-run and unaffected tiles don't; Enter/blur bypasses the debounce; a tile with
an unfilled param shows the placeholder and never calls runTile; filling it triggers a run; a
stale response (older generation) is discarded. tests/unit/ch-client.test.js: params reaches
the URL as param_<name>=<value> on queryDashboardTile/queryJson, backward-compatible when
omitted. tests/helpers/fake-app.js: add a saveVarValues spy (the fake app already exposes
real state.varValues via createState).
Design decisions (resolves #149's open questions for this phase)
Empty-filter semantics: an unset/empty param blocks that tile's execution entirely (the
existing unfilledParams gate, applied per-tile instead of globally) — no SQL-side sentinel
convention needed. This matches "Use the same approach as SQL Browser's {var:String}
processing" from the epic and is the simpler of the two options the epic left open.
No manual "Apply" step: the dashboard has no Run button, so a (debounced) filter edit
re-runs automatically — this is a deliberate UX divergence from the workbench's explicit-Run
gate, not an oversight; only unfilled params block execution, filled ones re-run on change.
Race safety via a per-tile generation counter, not request cancellation (no AbortSignal
plumbing exists on runTile today; adding it is out of scope here — discarding a stale response is sufficient since tile queries are cheap single-shot reads).
Acceptance criteria
A dashboard with no {name:Type} params in any favorite shows no filter row at all
(identical layout to before this phase).
A dashboard with favorites referencing {year:UInt16} (used by 2 tiles) and {region:String} (used by 1 different tile) shows two filter fields; typing in year
debounces, then re-runs only the 2 tiles that reference it — the region tile is
untouched (no loading state, no request).
Enter or blur on a filter field fires the re-run immediately, without waiting out the
debounce.
A tile whose SQL references a param with no value yet renders a visible "enter a value"
placeholder and never calls app.runTile; it is not counted in the "N not shown" note.
Filling that param's field runs the tile and replaces the placeholder with its
chart/skip outcome, same as any other tile.
A filter value typed on the dashboard is visible in the SQL Browser workbench's variable
strip for the same param name, and vice versa (shared state.varValues).
Rapid edits to the same field only apply the last value — an in-flight response for a
superseded edit never overwrites newer tile content.
Coverage gates hold: src/core/dashboard.js stays 100%; src/ui/dashboard.js stays at
its existing (lower, integration-tested) UI-layer gate; no new runtime dependency.
Dropdown/multiselect option lists from filter:/filter[]: Library queries and cascading
(#149 D6); per-tile Type/X/Y override controls and the expand modal (D7); KPI tiles (D5 — once
they land, they pick up this same filter mechanism for free since it operates on any favorite's
SQL, but wiring KPI-specific rendering is D5's job); drill-down (D7); drag-reorder/column-span
layout (D4, #153); export (D8).
Tracking
Phase D3 of #149. Depends on #151 (D2) merging first (extends its .dash-toolbar).
Goal
Add a global filter bar to the Dashboard: one text input per
{name:Type}parameterdetected across every favorited tile's SQL, sharing the same persisted
state.varValuestheSQL Browser workbench already uses (#134). Changing a value re-runs only the tiles that
reference it. No new parameter syntax, no schema change — this phase is "make the existing
{name:Type}mechanism work across a set of queries instead of one."Phase D3 of the Dashboard epic (#149 §6, "Global filters"). Builds directly on D1 (#150,
merged — read-only tiles via
app.runTile) and depends on D2 (#151) merging first: itextends the
.dash-toolbarrow D2 introduces (the Arrange/Report switcher), which the epicexplicitly designed as becoming the filter bar in this phase.
Current mechanism (reused, not reinvented)
The workbench already does everything this phase needs, just scoped to one query at a time —
see
src/core/query-params.js(#134/#138) andsrc/ui/app.js'srenderVarStrip/setRunBtn:detectParams(sql)— finds{name:Type}placeholders, skipping string/comment spans.readStatementParams(sql)— the same, restricted to row-returning statements, unique by name.unfilledParams(sql, values)/missingValues(params, values)— which detected names have no(or empty) value yet in a
{ name: value }map.paramArgs(stmt, values)— builds theparam_<name>HTTP args ClickHouse substitutesserver-side; no-ops on non-row-returning SQL; omits a name whose value is empty/absent.
state.varValues(asb:varValues, plain persisted object, already shared across theworkbench and any dashboard tab since both load the same localStorage key) +
app.saveVarValues().This phase is entirely about fan-out: applying the same primitives across every favorite's
SQL instead of one tab's, and deciding what "run" means when there's no Run button.
Scope
src/core/dashboard.js— add:Pure, imports
readStatementParamsfromquery-params.js. 100%-covered.src/net/ch-client.js— thread parameter substitution through the dashboard tile request,which today drops it entirely:
chUrlalready acceptsopts.params(used by the workbench'srunQuery) — this just forwardsit through the two dashboard-specific wrappers. Backward compatible: existing 2-arg call sites
keep working (
paramsdefaults toundefined→ noparam_*args added).src/ui/app.js—runTile(sql)keeps its signature but now substitutes, mirroring theworkbench's
run():(
paramArgs/unfilledParams/readStatementParamsare already imported inapp.jsfor theworkbench path — no new import.) Gating (whether to call
runTileat all) stays a UI-layerdecision in
dashboard.js, matching how the workbench's Run-button gate lives inapp.js's UIcode, not inside
ch.runQuery.src/ui/dashboard.js— the bulk of the work:Filter bar. A
.dash-filtersgroup inside the existing.dash-toolbar(D2), built fromdashboardParams(favorites): one<label class="var-field">+<input class="var-input">per param, reusing the workbench's existing CSS classes (
var-field/var-name/var-input,src/styles.css:1094-1125) rather than duplicating them. Hidden entirely (no row, nospacing) when
dashboardParams(favorites).length === 0— same convention as the workbench'svar-strip(strip.style.display = 'none'). Values read from / write toapp.state.varValues+app.saveVarValues(), so a value typed in the workbench and a valuetyped on the dashboard are the same value.
Never on the keystroke path. Per the standing editor-variables rule, typing must not fire
a query per character. Each field debounces (~500 ms idle after the last keystroke — same
order of magnitude as the FROM-scope column-load debounce in
codemirror-adapter.js, longerbecause this fires a real query, not a metadata fetch) before triggering a re-run; Enter or
blur fires immediately, bypassing the debounce, for a fast explicit-commit path.
Affected-tile re-run. On a (debounced) filter change, recompute
readStatementParams(q.sql)per favorite and re-run only the favorites whose param list includes the changed name — not
the whole grid. A per-tile monotonically increasing generation counter guards against
out-of-order responses (edit A, then B, before A's request returns; B's response must win).
Per-tile unfilled-parameter gating. Before calling
app.runTile(q.sql)(initial loador a filter-triggered re-run), compute
unfilledParams(q.sql, app.state.varValues). Ifnon-empty, do not run the query — render a distinct placeholder state instead (e.g. "Enter
a value for: year" — same wording
setRunBtn's tooltip uses), reusing the tile's card shell(header/footer chrome) so it doesn't look broken. Unlike
classifyTile'sskipoutcomes(empty/kpi/non-chartable), an unfilled tile stays visible — it's excluded from the "N not
shown" tally, since a value away it becomes chartable.
Stable per-favorite tile slots (refactor). Today
renderTileappends a new card perfavorite during the initial fan-out and
card.remove()s it onskip— fine for a one-shotbuild, but a filter change can flip a tile between skip ⇄ unfilled ⇄ chart repeatedly, and
removed/re-appended nodes would both reorder the grid and orphan the "same" tile's identity.
Refactor to: build one slot element per favorite up front, in favorite order, and have
every subsequent state transition (loading → chart / skip / unfilled / error, or a later
re-run) update that same slot's contents/visibility in place (
display:noneforskip,visible content otherwise) rather than inserting/removing DOM nodes. This is what makes step 3
safe.
liveTiles(the Chart.js-instance-per-tile teardown list) keyed by favorite indexinstead of append order.
Live skip-note. The header's "N not shown" note currently is computed once at the end of
the initial fan-out. Recompute it from live per-slot status any time a slot's classification
changes (initial load or a filtered re-run), not just once.
src/styles.css—.dash-tile-unfilled(placeholder content style, echoing.dash-tile-error/.dash-tile-load); place.dash-filtersinside.dash-toolbar's existingflex-wrap row (no new sticky/scroll container needed — the toolbar already wraps at narrow
widths).
Tests —
tests/unit/dashboard.test.js:dashboardParamspure cases (dedup acrossfavorites, first-appearance order, ignores non-row-returning SQL); filter bar renders one field
per detected param and stays hidden with none; typing debounces (fake timers) before the
affected tile(s) re-run and unaffected tiles don't; Enter/blur bypasses the debounce; a tile with
an unfilled param shows the placeholder and never calls
runTile; filling it triggers a run; astale response (older generation) is discarded.
tests/unit/ch-client.test.js:paramsreachesthe URL as
param_<name>=<value>onqueryDashboardTile/queryJson, backward-compatible whenomitted.
tests/helpers/fake-app.js: add asaveVarValuesspy (the fake app already exposesreal
state.varValuesviacreateState).Design decisions (resolves #149's open questions for this phase)
existing
unfilledParamsgate, applied per-tile instead of globally) — no SQL-side sentinelconvention needed. This matches "Use the same approach as SQL Browser's
{var:String}processing" from the epic and is the simpler of the two options the epic left open.
re-runs automatically — this is a deliberate UX divergence from the workbench's explicit-Run
gate, not an oversight; only unfilled params block execution, filled ones re-run on change.
state.varValues, not a dashboard-scoped copy — avalue set on the dashboard is visible in the workbench and vice versa (pinned in Feature: open favorited Library queries as an interactive Dashboard #149).
plumbing exists on
runTiletoday; adding it is out of scope here — discarding a staleresponse is sufficient since tile queries are cheap single-shot reads).
Acceptance criteria
{name:Type}params in any favorite shows no filter row at all(identical layout to before this phase).
{year:UInt16}(used by 2 tiles) and{region:String}(used by 1 different tile) shows two filter fields; typing inyeardebounces, then re-runs only the 2 tiles that reference it — the
regiontile isuntouched (no loading state, no request).
debounce.
placeholder and never calls
app.runTile; it is not counted in the "N not shown" note.chart/skip outcome, same as any other tile.
strip for the same param name, and vice versa (shared
state.varValues).superseded edit never overwrites newer tile content.
src/core/dashboard.jsstays 100%;src/ui/dashboard.jsstays atits existing (lower, integration-tested) UI-layer gate; no new runtime dependency.
Non-goals (later phases, per #149)
Dropdown/multiselect option lists from
filter:/filter[]:Library queries and cascading(#149 D6); per-tile Type/X/Y override controls and the expand modal (D7); KPI tiles (D5 — once
they land, they pick up this same filter mechanism for free since it operates on any favorite's
SQL, but wiring KPI-specific rendering is D5's job); drill-down (D7); drag-reorder/column-span
layout (D4, #153); export (D8).
Tracking
Phase D3 of #149. Depends on #151 (D2) merging first (extends its
.dash-toolbar).