Skip to content

**Phase D3 — global filters (text)**: filter bar over detected {name:Type} params, re-run affected tiles #152

Description

@BorisTyshkevich

Goal

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:

  • 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 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.
export function dashboardParams(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:

export async function queryJson(ctx, sql, signal, extra, params) {
  const resp = await authedFetch(ctx, chUrl(ctx.origin, { format: 'JSON', extra, params }), sql, signal);
  ...
}
export function queryDashboardTile(ctx, sql, signal, params) {
  return queryJson(ctx, sql, signal, { readonly: 2 }, params);
}

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.jsrunTile(sql) keeps its signature but now substitutes, mirroring the
workbench's run():

const json = await ch.queryDashboardTile(chCtx, dashboardTileSql(sql), undefined,
  paramArgs(sql, app.state.varValues));

(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:

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

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

  3. 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).

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

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

  6. 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).

Teststests/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.
  • Filter values are the existing global state.varValues, not a dashboard-scoped copy — a
    value set on the dashboard is visible in the workbench and vice versa (pinned in Feature: open favorited Library queries as an interactive Dashboard #149).
  • 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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions