From 5ed8b94bedba868b9afb2781a8fc3d78356877ea Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Sat, 4 Jul 2026 18:07:13 +0000 Subject: [PATCH] feat: dashboard global filter bar (#149 D3) Add a filter bar over every {name:Type} param detected across favorited tiles' SQL, sharing state.varValues with the SQL Browser workbench (#134). A debounced (Enter/blur-bypassed) edit re-runs only the affected tiles; an unfilled param blocks that tile with a placeholder instead of running. Refactors dashboard tiles to stable per-favorite slots updated in place (loading/unfilled/error/chart) with a per-slot generation counter, so a filter edit can flip a tile's state repeatedly without reordering the grid or racing a stale response. Threads an optional params arg through ch-client.js's queryJson/queryDashboardTile (backward compatible). Closes #152. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GyLqZGyUkm7mP6WhZCkodj --- CHANGELOG.md | 24 +++- src/core/dashboard.js | 24 ++++ src/net/ch-client.js | 16 ++- src/styles.css | 10 ++ src/ui/app.js | 12 +- src/ui/dashboard.js | 238 +++++++++++++++++++++++++-------- tests/helpers/fake-app.js | 1 + tests/unit/ch-client.test.js | 19 +++ tests/unit/dashboard.test.js | 247 ++++++++++++++++++++++++++++++++++- 9 files changed, 523 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4086232..7afdfe2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,7 +42,29 @@ auto-generated per-PR notes; this file is the curated, human-readable history. name, the chart now draws on the **tile's own background** (instead of the darker results-table background), and the value-axis **gridlines are hidden** on tiles (they read as noisy light lines on a dark panel). Drag-to-reorder and - 1/2-column tile spans arrive in the next phase (#149 D3). + 1/2-column tile spans arrive in a later phase (#149 D4). +- **Dashboard (phase 3): global filter bar** (#149, #152). A **filter bar** in + the dashboard toolbar renders one text field per `{name:Type}` parameter + detected across every favorited tile's SQL (`dashboardParams`, unique by + name, first-appearance order) — absent entirely when no favorite has one. + Fields share the same persisted `state.varValues` the SQL Browser workbench + already uses (#134): a value typed on the dashboard shows up in the + workbench's variable strip for the same name, and vice versa. Typing + debounces (~500 ms idle) before re-running only the tiles that reference the + changed name — not the whole grid; Enter or blur commits immediately, + bypassing the debounce. A tile whose SQL still has an empty/absent parameter + never runs its query — it shows a distinct "Enter a value for: …" placeholder + (excluded from the "N not shown" count, since one filter value away it + becomes chartable). Tiles now live in **stable per-favorite slots** built up + front and updated in place (loading/unfilled/error/chart) rather than + inserted/removed, so a filter-driven tile flipping states repeatedly never + reorders the grid or orphans its identity; each slot's fetch carries a + monotonically increasing generation counter so a superseded in-flight + response can never overwrite a newer edit's result. `ch-client.js`'s + `queryJson`/`queryDashboardTile` gained an optional `params` argument + (backward compatible) to forward `param_` args to ClickHouse. Per-tile + Type/X/Y overrides, KPI tiles, and dropdown/cascading filters arrive in later + phases (#149 D5–D7). - **Schema-aware, FROM-driven autocompletion** (#84) — column completion now fires *while you type*, driven by the statement's `FROM`/`JOIN` clause, so you no longer have to expand a table in the sidebar first. A new pure module diff --git a/src/core/dashboard.js b/src/core/dashboard.js index faaff34..2ba6fd2 100644 --- a/src/core/dashboard.js +++ b/src/core/dashboard.js @@ -9,6 +9,7 @@ import { autoChart, chartCfgValid, cloneChartCfg, normalizeChartCfg } from './chart-data.js'; import { withTrailingFormat } from './format.js'; +import { readStatementParams } from './query-params.js'; /** * True on the standalone dashboard route (a path ending in `/dashboard`, @@ -84,6 +85,29 @@ export function parseJsonResult(json) { }; } +/** + * The union of every `{name:Type}` parameter referenced by any favorite's + * row-returning SQL (#149 D3): unique by name, first-appearance order + * (favorite order, then in-SQL order — `readStatementParams`' own order per + * favorite). Drives which fields the dashboard's global filter bar renders; + * a favorite with no row-returning statement contributes nothing. Pure. + * @param {{sql: string}[]} favorites + * @returns {{name: string, type: string}[]} + */ +export function dashboardParams(favorites) { + const out = []; + const seen = new Set(); + for (const fav of favorites || []) { + for (const p of readStatementParams(fav.sql)) { + if (!seen.has(p.name)) { + seen.add(p.name); + out.push(p); + } + } + } + return out; +} + /** * Classify a favorite's result into a dashboard tile. In D1: * - 0 rows → skip (empty) diff --git a/src/net/ch-client.js b/src/net/ch-client.js index 8857195..cefc5f3 100644 --- a/src/net/ch-client.js +++ b/src/net/ch-client.js @@ -80,10 +80,12 @@ export async function authedFetch(ctx, url, sql, signal) { /** * Run a query and return parsed JSON (FORMAT JSON). Throws on CH error. `signal` * (optional) aborts the request. `extra` (optional) adds HTTP query-string - * settings (e.g. `{ readonly: 2 }` for a read-only tile). + * settings (e.g. `{ readonly: 2 }` for a read-only tile). `params` (optional) + * adds `param_` query-string args for native ClickHouse query parameters + * (#134) — omitted for every existing call site, so this is backward compatible. */ -export async function queryJson(ctx, sql, signal, extra) { - const resp = await authedFetch(ctx, chUrl(ctx.origin, { format: 'JSON', extra }), sql, signal); +export async function queryJson(ctx, sql, signal, extra, params) { + const resp = await authedFetch(ctx, chUrl(ctx.origin, { format: 'JSON', extra, params }), sql, signal); if (!resp.ok) throw new Error(parseExceptionText(await resp.text())); return resp.json(); } @@ -93,10 +95,12 @@ export async function queryJson(ctx, sql, signal, extra) { * the `readonly=2` HTTP setting, so a favorite that happens to contain a write * (INSERT / ALTER / DROP / …) is rejected server-side rather than executed when * the dashboard opens or refreshes — level 2 still permits SELECT and - * query-level `SETTINGS`. Returns parsed JSON; throws CH's reason on error. + * query-level `SETTINGS`. `params` (optional, #149 D3) forwards `param_` + * args for the dashboard's global filter bar. Returns parsed JSON; throws CH's + * reason on error. */ -export function queryDashboardTile(ctx, sql, signal) { - return queryJson(ctx, sql, signal, { readonly: 2 }); +export function queryDashboardTile(ctx, sql, signal, params) { + return queryJson(ctx, sql, signal, { readonly: 2 }, params); } /** diff --git a/src/styles.css b/src/styles.css index 7fe70cc..5dc6260 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1767,6 +1767,11 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } .dash-cols-wrap { display: inline-flex; align-items: center; gap: 8px; } .dash-seg-cols .dash-seg-btn { padding: 4px 10px; font-family: var(--mono); } .dash-seg-label { font-size: 11px; color: var(--fg-faint); } +/* Global filter bar (#149 D3) — one field per detected {name:Type}, reusing the + workbench's var-field/var-name/var-input classes. Sits in the toolbar's + existing flex-wrap row (no new sticky/scroll container needed); absent + entirely (display:none, no gap) when no favorite has a param. */ +.dash-filters { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; } .dash-icobtn { width: 30px; height: 30px; display: inline-flex; align-items: center; justify-content: center; background: var(--bg-chip); color: var(--fg-mute); border: 1px solid var(--border); @@ -1845,6 +1850,11 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } border: 1px solid var(--error-bd); border-radius: 8px; font-size: 12px; font-family: var(--mono); max-width: 90%; } +/* A tile blocked on an empty {name:Type} filter value (#149 D3) — one filter + value away from chartable, so it reads as a prompt, not an error. */ +.dash-tile-unfilled { + margin: auto; padding: 12px; color: var(--fg-mute); font-size: 12px; text-align: center; +} .dash-tile-foot { display: flex; align-items: center; gap: 12px; padding: 7px 12px; border-top: 1px solid var(--border-faint); font-family: var(--mono); diff --git a/src/ui/app.js b/src/ui/app.js index e04817c..404abe5 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -1647,9 +1647,12 @@ export function createApp(env = {}) { // Run one favorite's SQL for a dashboard tile: read-only (writes rejected // server-side by queryDashboardTile), FORMAT JSON, transformed to the - // array-row shape renderChart wants. Returns { columns, rows, meta } on - // success or { error } on failure. The token is resolved up front by - // ensureFreshToken (above), so this does not itself drive sign-out. + // array-row shape renderChart wants. Substitutes the shared `state.varValues` + // as `param_` args (#149 D3), mirroring the workbench's run() — a + // no-op when the tile's SQL has no `{name:Type}` placeholder. Returns + // { columns, rows, meta } on success or { error } on failure. The token is + // resolved up front by ensureFreshToken (above), so this does not itself + // drive sign-out. async function runTile(sql) { try { // ensureConfig + getToken are inside the try: getToken→refresh can THROW on @@ -1659,7 +1662,8 @@ export function createApp(env = {}) { // cheap and keeps runTile usable on its own. await ensureConfig(); if (!(await getToken())) return { error: 'Not signed in' }; - const json = await ch.queryDashboardTile(chCtx, dashboardTileSql(sql)); + const json = await ch.queryDashboardTile(chCtx, dashboardTileSql(sql), undefined, + paramArgs(sql, app.state.varValues)); return parseJsonResult(json); } catch (e) { return { error: String((e && e.message) || e) }; diff --git a/src/ui/dashboard.js b/src/ui/dashboard.js index 43e9d79..e8689a3 100644 --- a/src/ui/dashboard.js +++ b/src/ui/dashboard.js @@ -1,23 +1,33 @@ -// The standalone read-only Dashboard page (#149 D1). Render module over the +// The standalone read-only Dashboard page (#149 D1–D3). Render module over the // `app` controller: it builds a header + a grid of chart tiles, one per // favorited Library query (a snapshot taken when the tab opens — Refresh re-runs // the data, it does not re-scan the Library). Each tile runs its SQL read-only // via `app.runTile` and draws through the shared `renderChart` seam; single-row -// (KPI) and non-chartable favorites are skipped, counted in a header note. KPI -// tiles, filters, layout, and export arrive in later phases (D2–D7). +// (KPI) and non-chartable favorites are skipped, counted in a header note. A +// global filter bar (D3, below) drives the same `{name:Type}` mechanism the SQL +// Browser workbench uses, fanning it out across every favorite instead of one +// query at a time. KPI tiles, per-tile overrides, and export arrive in later +// phases (D5–D8). import { h } from './dom.js'; import { Icon } from './icons.js'; import { renderChart } from './results.js'; import { schemaKey } from '../core/chart-data.js'; -import { classifyTile } from '../core/dashboard.js'; +import { classifyTile, dashboardParams } from '../core/dashboard.js'; import { formatBytes, formatRows } from '../core/format.js'; +import { readStatementParams, unfilledParams } from '../core/query-params.js'; // At most this many tile queries run at once, so a large favorites list doesn't // fire a thundering herd of concurrent reads at ClickHouse (saturating the // browser's per-host pool and the cluster) on open and on every Refresh. const TILE_CONCURRENCY = 6; +// Idle time after the last keystroke in a filter field before it triggers a +// re-run (#149 D3) — longer than the FROM-scope column-load debounce +// (codemirror-adapter.js) since this fires a real query, not a metadata fetch. +// Enter/blur bypass this entirely for a fast explicit-commit path. +const FILTER_DEBOUNCE_MS = 500; + /** * Build a segmented control (`Arrange | Report`, `2 | 3`): a row of buttons of * which exactly one reads active. `getActive` returns the currently-selected @@ -66,15 +76,18 @@ async function runPool(items, limit, worker) { return results; } -// Render one favorite into a freshly-appended tile card: run its SQL (via -// app.runTile), then draw the chart, drop the card (skip), or show the error. -// Resolves to the outcome ('chart' | 'skip' | 'error') so the caller can tally -// the skipped count. A chartable tile pushes a `{ destroy }` handle onto `tiles` -// so the caller can tear its Chart.js instance down on the next Refresh (else -// orphaned charts + their ResizeObservers leak on a long-lived tab). -async function renderTile(app, q, grid, tiles) { - const body = h('div', { class: 'dash-tile-body' }, - h('div', { class: 'dash-tile-load' }, Icon.spinner(), h('span', null, 'Loading…'))); +// One favorite's tile card, built once per dashboard load (favorite order) and +// never removed/re-appended: a filter change can flip a tile between +// skip ⇄ unfilled ⇄ chart repeatedly, and removing/re-inserting DOM nodes would +// both reorder the grid and orphan the "same" tile's identity. Every later +// state transition (`setSlotLoading`/`setSlotUnfilled`/`applyTileResult`) +// updates this same slot's contents/visibility in place instead. `gen` is a +// per-tile monotonically increasing generation counter guarding against +// out-of-order responses (edit A, then B, before A's request returns — B's +// response must win); `destroy` tears down the slot's live Chart.js instance +// (if any) before it's replaced. +function buildTileSlot(q) { + const body = h('div', { class: 'dash-tile-body' }); const foot = h('div', { class: 'dash-tile-foot' }); // Header: the favorite's name, plus its saved description as a subtitle when it // has one (single line, ellipsized) — mirrors the design mockup's tile header. @@ -82,31 +95,123 @@ async function renderTile(app, q, grid, tiles) { h('span', { class: 'dash-tile-name', title: q.name }, q.name)); if (q.description) head.appendChild(h('div', { class: 'dash-tile-desc', title: q.description }, q.description)); const card = h('div', { class: 'dash-tile' }, head, body, foot); - grid.appendChild(card); + return { card, body, foot, gen: 0, status: null, destroy: null }; +} - const r = await app.runTile(q.sql); +function destroySlotChart(slot) { + if (slot.destroy) { slot.destroy(); slot.destroy = null; } +} + +function setSlotLoading(slot) { + destroySlotChart(slot); + slot.card.style.display = ''; + slot.body.replaceChildren(h('div', { class: 'dash-tile-load' }, Icon.spinner(), h('span', null, 'Loading…'))); + slot.foot.replaceChildren(); +} + +// A tile whose SQL still has an empty/absent {name:Type} value never calls +// app.runTile — it shows this placeholder instead (reusing the card's header/ +// footer chrome so it doesn't look broken), and stays visible: unlike a +// classifyTile `skip`, one filter value away it becomes chartable, so it is +// NOT counted in the header's "N not shown" note. +function setSlotUnfilled(slot, missing) { + destroySlotChart(slot); + slot.status = 'unfilled'; + slot.card.style.display = ''; + slot.body.replaceChildren(h('div', { class: 'dash-tile-unfilled' }, 'Enter a value for: ' + missing.join(', '))); + slot.foot.replaceChildren(); +} + +function applyTileResult(app, q, slot, r) { + destroySlotChart(slot); if (r.error != null) { - body.replaceChildren(h('div', { class: 'dash-tile-error' }, r.error)); - return 'error'; + slot.status = 'error'; + slot.card.style.display = ''; + slot.body.replaceChildren(h('div', { class: 'dash-tile-error' }, r.error)); + slot.foot.replaceChildren(); + return; } const cls = classifyTile(r.columns, r.rows, q.chart); - if (cls.kind === 'skip') { card.remove(); return 'skip'; } - + if (cls.kind === 'skip') { + slot.status = 'skip'; + slot.card.style.display = 'none'; + // Clear a previous chart's DOM (its Chart.js instance is already torn + // down by destroySlotChart above) so a tile that flips chart → skip on a + // later refresh/filter change doesn't leave a dead canvas hidden in the DOM. + slot.body.replaceChildren(); + slot.foot.replaceChildren(); + return; + } + slot.status = 'chart'; + slot.card.style.display = ''; // Seed an isolated per-tile config with the resolved cfg + its schema key so // renderChart honours it (a schema-key mismatch would make it re-derive with // autoChart, discarding a favorite's saved chart shape). controls:false — D1 // tiles are read-only, so renderChart omits the Type/X/Y config bar entirely - // (and so never re-renders); its Chart.js instance is torn down centrally on - // the next Refresh via the `tiles` handle below. + // (and so never re-renders); its Chart.js instance is torn down via + // destroySlotChart above, on the next result for this same slot. const res = { columns: r.columns, rows: r.rows }; const chartTab = { chartKey: schemaKey(r.columns), chartCfg: cls.cfg }; let inst = null; - body.replaceChildren(renderChart(app, res, { + slot.body.replaceChildren(renderChart(app, res, { tab: chartTab, setChart: (c) => { inst = c; }, running: false, controls: false, hideGrid: true, })); - tiles.push({ destroy: () => inst.destroy() }); - foot.replaceChildren(...tileFooter(r.meta)); - return 'chart'; + slot.destroy = () => inst.destroy(); + slot.foot.replaceChildren(...tileFooter(r.meta)); +} + +// Run (or re-run) one favorite's tile into its slot: gate on unfilled +// `{name:Type}` values first (never calling app.runTile while any are empty), +// otherwise fetch and classify. `onSettled()` fires after every transition +// (unfilled or fetched) so the caller can recompute the live "N not shown" +// count. The generation bump happens before the gate check so a superseded +// in-flight fetch is discarded even if the newer edit resolves to "unfilled". +async function runSlotTile(app, q, slot, onSettled) { + const myGen = ++slot.gen; + const missing = unfilledParams(q.sql, app.state.varValues); + if (missing.length) { + setSlotUnfilled(slot, missing); + onSettled(); + return; + } + setSlotLoading(slot); + const r = await app.runTile(q.sql); + if (slot.gen !== myGen) return; // a newer edit started after this fetch; discard + applyTileResult(app, q, slot, r); + onSettled(); +} + +// The global filter bar (#149 D3): one field per `{name:Type}` parameter +// referenced by any favorite, sharing `app.state.varValues` with the SQL +// Browser workbench. Hidden entirely (no row, no spacing) when there are no +// detected params — same convention as the workbench's `var-strip`. Typing +// debounces before calling `onCommit(name)`; Enter or blur fires immediately, +// clearing any pending debounce so a value never applies twice. +function buildFilterBar(app, params, onCommit) { + if (!params.length) return h('div', { class: 'dash-filters', style: { display: 'none' } }); + return h('div', { class: 'dash-filters' }, ...params.map((p) => { + let timer = null; + const commitNow = () => { + if (timer == null) return; + clearTimeout(timer); + timer = null; + onCommit(p.name); + }; + const input = h('input', { + type: 'text', class: 'var-input', + value: app.state.varValues[p.name] || '', + placeholder: p.type, title: p.name + ': ' + p.type, 'aria-label': p.name, + oninput: (e) => { + app.state.varValues[p.name] = e.target.value; + app.saveVarValues(); + clearTimeout(timer); + timer = setTimeout(commitNow, FILTER_DEBOUNCE_MS); + }, + onkeydown: (e) => { if (e.key === 'Enter') commitNow(); }, + onblur: commitNow, + }); + return h('label', { class: 'var-field' }, h('span', { class: 'var-name' }, p.name), input); + })); } /** Render the dashboard into `app.root`. */ @@ -149,14 +254,15 @@ export function renderDashboard(app) { const empty = h('div', { class: 'dash-empty', style: { display: favorites.length ? 'none' : '' } }, 'No favorites yet — star a query in the Library to add it to the dashboard.'); - // Layout toolbar (#149 D2), the row that becomes the filter bar in D4. The - // Arrange|Report switcher is the primary control; the 2/3 column count is a - // secondary setting, meaningful only in Arrange (hidden in Report's single - // column). Both are presentation-only: `apply()` reshapes the grid and the - // tiles' Chart.js instances resize themselves via their ResizeObserver — no - // tile re-query. State is mutated + persisted (asb:dashLayout/dashCols) so the - // choice survives reloads and Refresh (which rebuilds the grid's children, not - // the grid element, so its class/`--dash-cols` persist across a refresh). + // Layout toolbar (#149 D2) + global filter bar (#149 D3). The Arrange|Report + // switcher is the primary control; the 2/3 column count is a secondary + // setting, meaningful only in Arrange (hidden in Report's single column). + // Both are presentation-only: `apply()` reshapes the grid and the tiles' + // Chart.js instances resize themselves via their ResizeObserver — no tile + // re-query. State is mutated + persisted (asb:dashLayout/dashCols) so the + // choice survives reloads and Refresh. The filter bar sits between them; it + // is entirely absent (no row, no spacing) when no favorite references a + // `{name:Type}` parameter. const apply = () => { grid.classList.toggle('is-report', state.dashLayout === 'report'); grid.style.setProperty('--dash-cols', String(state.dashCols)); @@ -180,8 +286,10 @@ export function renderDashboard(app) { }); const colsWrap = h('div', { class: 'dash-cols-wrap' }, h('span', { class: 'dash-seg-label' }, 'Columns'), colsSeg.el); + const filterBar = buildFilterBar(app, dashboardParams(favorites), (name) => runAffected(name)); const toolbar = h('div', { class: 'dash-toolbar' }, layoutSeg.el, + filterBar, h('div', { class: 'dash-spacer', style: { flex: '1' } }), colsWrap); apply(); @@ -192,39 +300,59 @@ export function renderDashboard(app) { app.root.replaceChildren(h('div', { class: 'dash-page' }, h('div', { class: 'dash-topbar' }, header, toolbar), empty, grid)); - // Chart.js instances of the tiles currently in the grid, torn down before the - // next Refresh rebuilds them (grid.replaceChildren() alone would orphan them, - // leaking the charts + their ResizeObservers on a long-lived tab). - let liveTiles = []; + // One stable slot per favorite (favorite order), built lazily on the first + // successful run (below) and reused for the tab's lifetime — a filter edit + // or Refresh updates a slot's contents/visibility in place rather than + // inserting/removing grid children (see buildTileSlot). + let slots = []; - const refresh = async () => { + const updateSkipNote = () => { + const skipped = slots.filter((s) => s.status === 'skip').length; + if (skipped) { + skipNote.style.display = ''; + skipNote.textContent = skipped + ' not shown'; + skipNote.title = skipped + ' single-row (KPI) or non-chartable favorite(s) — coming in a later phase.'; + } else { + skipNote.style.display = 'none'; + } + }; + + // Re-run only the favorites whose SQL references `name` (a filter field's + // debounced/committed edit, #149 D3) — not the whole grid. A no-op before + // the first successful run (slots not built yet). + function runAffected(name) { + if (!slots.length) return; + const targets = favorites + .map((q, i) => [q, i]) + .filter(([q]) => readStatementParams(q.sql).some((p) => p.name === name)); + return Promise.all(targets.map(([q, i]) => runSlotTile(app, q, slots[i], updateSkipNote))); + } + + const runAll = async () => { // Resolve (and refresh) the auth token ONCE up front. This both avoids N // tiles racing an expired-token refresh and lets a lost session redirect to // login exactly once — rather than each tile firing onSignedOut in parallel. if (!(await app.ensureFreshToken())) { app.chCtx.onSignedOut(); return; } refreshBtn.disabled = true; - liveTiles.forEach((t) => t.destroy()); - liveTiles = []; - grid.replaceChildren(); - let skipped = 0; + if (!slots.length) { + slots = favorites.map((q) => buildTileSlot(q)); + slots.forEach((s) => grid.appendChild(s.card)); + } + // Every favorite re-runs on a full refresh (unlike a filter's targeted + // runAffected). Mark every slot loading up front rather than leaving + // tiles beyond TILE_CONCURRENCY's window showing stale content (or, on + // first load, an empty card) until the pool gets around to them. + slots.forEach((s) => setSlotLoading(s)); // try/finally so the button always re-enables and the timestamp always - // updates — even if a tile render unexpectedly throws (runTile itself is - // total, so this is belt-and-suspenders against the pool rejecting). + // updates — even if a tile render unexpectedly throws (runSlotTile itself + // is total, so this is belt-and-suspenders against the pool rejecting). try { - const outcomes = await runPool(favorites, TILE_CONCURRENCY, (q) => renderTile(app, q, grid, liveTiles)); - skipped = outcomes.filter((o) => o === 'skip').length; + await runPool(favorites, TILE_CONCURRENCY, (q, i) => runSlotTile(app, q, slots[i], updateSkipNote)); } finally { - if (skipped) { - skipNote.style.display = ''; - skipNote.textContent = skipped + ' not shown'; - skipNote.title = skipped + ' single-row (KPI) or non-chartable favorite(s) — coming in a later phase.'; - } else { - skipNote.style.display = 'none'; - } updated.textContent = 'Updated ' + new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); refreshBtn.disabled = false; } }; - refreshBtn.onclick = refresh; - return refresh(); + refreshBtn.onclick = runAll; + return runAll(); } diff --git a/tests/helpers/fake-app.js b/tests/helpers/fake-app.js index 693642a..57f6a8b 100644 --- a/tests/helpers/fake-app.js +++ b/tests/helpers/fake-app.js @@ -48,6 +48,7 @@ export function makeApp(over = {}) { toggleTheme: vi.fn(), email: () => 'me@example.com', savePref: vi.fn(), + saveVarValues: vi.fn(), saveJSON: vi.fn(), saveStr: vi.fn(), downloadFile: vi.fn(), diff --git a/tests/unit/ch-client.test.js b/tests/unit/ch-client.test.js index a9932ea..1b71560 100644 --- a/tests/unit/ch-client.test.js +++ b/tests/unit/ch-client.test.js @@ -69,6 +69,18 @@ describe('queryDashboardTile', () => { const ctx = ctxWith(async () => textResp('Code: 164. DB::Exception: Cannot execute query in readonly mode', false, 500)); await expect(queryDashboardTile(ctx, 'DROP TABLE t')).rejects.toThrow(/readonly mode/); }); + it('forwards params as param_ query-string args (#149 D3)', async () => { + const ctx = ctxWith(async () => jsonResp({ meta: [], data: [] })); + await queryDashboardTile(ctx, 'SELECT {year:UInt16}\nFORMAT JSON', undefined, { param_year: '2024' }); + const url = ctx.fetch.mock.calls[0][0]; + expect(url).toContain('param_year=2024'); + }); + it('omits params entirely when not passed (backward compatible)', async () => { + const ctx = ctxWith(async () => jsonResp({ meta: [], data: [] })); + await queryDashboardTile(ctx, 'SELECT 1\nFORMAT JSON'); + const url = ctx.fetch.mock.calls[0][0]; + expect(url).not.toContain('param_'); + }); }); describe('authedFetch', () => { @@ -151,6 +163,13 @@ describe('queryJson', () => { const ctx = ctxWith(async () => textResp('{"exception":"DB::Exception: x"}', false, 500)); await expect(queryJson(ctx, 'bad')).rejects.toThrow('DB::Exception: x'); }); + it('forwards params as param_ query-string args, omitted when absent', async () => { + const ctx = ctxWith(async () => jsonResp({ data: [] })); + await queryJson(ctx, 'SELECT {id:UInt32}', undefined, undefined, { param_id: '5' }); + expect(ctx.fetch.mock.calls[0][0]).toContain('param_id=5'); + await queryJson(ctx, 'SELECT 1'); + expect(ctx.fetch.mock.calls[1][0]).not.toContain('param_'); + }); }); describe('loadServerVersion', () => { diff --git a/tests/unit/dashboard.test.js b/tests/unit/dashboard.test.js index 6070867..0caa87f 100644 --- a/tests/unit/dashboard.test.js +++ b/tests/unit/dashboard.test.js @@ -2,7 +2,7 @@ import { describe, it, expect, vi } from 'vitest'; import { webcrypto } from 'node:crypto'; import { isDashboardRoute, configBase, dashboardTileSql, parseJsonResult, classifyTile, - normalizeDashLayout, normalizeDashCols, + normalizeDashLayout, normalizeDashCols, dashboardParams, } from '../../src/core/dashboard.js'; import { AUTH_SS_KEYS, AUTH_REQUEST, AUTH_GRANT, @@ -126,6 +126,27 @@ describe('normalizeDashCols', () => { }); }); +describe('dashboardParams', () => { + it('unions params across favorites, unique by name, first-appearance order', () => { + const favorites = [ + { sql: 'SELECT * FROM t WHERE y = {year:UInt16}' }, + { sql: 'SELECT * FROM u WHERE y = {year:UInt16} AND r = {region:String}' }, + ]; + expect(dashboardParams(favorites)).toEqual([ + { name: 'year', type: 'UInt16' }, + { name: 'region', type: 'String' }, + ]); + }); + it('ignores a param that only appears in a non-row-returning statement', () => { + const favorites = [{ sql: "CREATE VIEW v AS SELECT {x:String}" }]; + expect(dashboardParams(favorites)).toEqual([]); + }); + it('is defensive about an empty/absent favorites list', () => { + expect(dashboardParams([])).toEqual([]); + expect(dashboardParams(undefined)).toEqual([]); + }); +}); + // ── core/auth-handoff.js ───────────────────────────────────────────────────── function memSession(initial = {}) { const m = new Map(Object.entries(initial)); @@ -234,7 +255,11 @@ describe('renderDashboard', () => { const runTile = vi.fn(async (sql) => (sql === 'kpi' ? kpiResult() : chartResult())); const app = dashApp(favorites, runTile); await renderDashboard(app); - expect(app.root.querySelectorAll('.dash-tile').length).toBe(1); // KPI tile removed + // Stable per-favorite slots (#149 D3): a skipped tile's card stays in the + // DOM (its identity is preserved for a later filter re-run) but hidden. + const tiles = [...app.root.querySelectorAll('.dash-tile')]; + expect(tiles.length).toBe(2); + expect(tiles.filter((t) => t.style.display !== 'none')).toHaveLength(1); const note = app.root.querySelector('.dash-skip'); expect(note.style.display).toBe(''); expect(note.textContent).toBe('1 not shown'); @@ -295,6 +320,43 @@ describe('renderDashboard', () => { expect(charts[0].destroyed).toBe(true); // prior instance destroyed, not orphaned }); + it('a tile that flips chart -> skip on Refresh clears its old chart DOM (no dead canvas lingers)', async () => { + const runTile = vi.fn(async () => chartResult()); + const app = dashApp([{ id: '1', name: 'Q', sql: 'q', favorite: true }], runTile); + await renderDashboard(app); + expect(app.root.querySelector('.dash-tile canvas')).not.toBeNull(); + runTile.mockImplementation(async () => kpiResult()); // next refresh becomes a skip (KPI) + await app.root.querySelector('.dash-btn').onclick(); + expect(app.root.querySelector('.dash-tile').style.display).toBe('none'); + expect(app.root.querySelector('.dash-tile canvas')).toBeNull(); // stale chart DOM cleared, not just hidden + }); + + it('Refresh marks every tile loading immediately (no stale content lingers beyond the concurrency window)', async () => { + const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + const favorites = Array.from({ length: 8 }, (_, i) => ({ id: String(i), name: 'Q' + i, sql: 'q' + i, favorite: true })); + const runTile = vi.fn(async () => chartResult()); + const app = dashApp(favorites, runTile); + await renderDashboard(app); + expect(app.root.querySelectorAll('.dash-tile canvas').length).toBe(8); + + const resolvers = []; + runTile.mockImplementation(() => new Promise((resolve) => resolvers.push(resolve))); + const refreshed = app.root.querySelector('.dash-btn').onclick(); + await flush(); + // All 8 tiles show "Loading…" up front, even though TILE_CONCURRENCY (6) + // means only 6 queries are actually in flight — none show the prior chart. + expect(app.root.querySelectorAll('.dash-tile-load').length).toBe(8); + expect(app.root.querySelectorAll('.dash-tile canvas').length).toBe(0); + // TILE_CONCURRENCY (6) means only 6 of the 8 queries are in flight yet; + // resolving them frees pool slots for the remaining 2 — drain in rounds. + for (let round = 0; round < 4; round++) { + resolvers.splice(0).forEach((r) => r(chartResult())); + await flush(); + } + await refreshed; + expect(app.root.querySelectorAll('.dash-tile canvas').length).toBe(8); + }); + it('shows an empty state when there are no favorites', async () => { const app = dashApp([], vi.fn()); await renderDashboard(app); @@ -382,6 +444,178 @@ describe('renderDashboard', () => { }); }); +// ── D3: global filter bar ──────────────────────────────────────────────────── +describe('renderDashboard — global filter bar (#149 D3)', () => { + const paramFav = (id, sql) => ({ id, name: id, sql, favorite: true }); + const setInput = (el, value) => { + el.value = value; + el.dispatchEvent(new Event('input', { bubbles: true })); + }; + const pressEnter = (el) => el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + // A macrotask tick — flushes every pending microtask (including chained + // awaits across runSlotTile/runPool), unlike a single `await Promise.resolve()`. + const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + const fieldInput = (root, name) => root.querySelector('.var-field input[aria-label="' + name + '"]'); + + it('shows no filter row when no favorite has a {name:Type} param', async () => { + const app = dashApp([{ id: '1', name: 'Q', sql: 'SELECT 1', favorite: true }], vi.fn(async () => chartResult())); + await renderDashboard(app); + const filters = app.root.querySelector('.dash-filters'); + expect(filters.style.display).toBe('none'); + expect(filters.querySelectorAll('.var-field').length).toBe(0); + }); + + it('renders one field per param detected across favorites, first-appearance order', async () => { + const favorites = [ + paramFav('1', 'SELECT * FROM t WHERE y = {year:UInt16}'), + paramFav('2', 'SELECT * FROM u WHERE r = {region:String}'), + ]; + const app = dashApp(favorites, vi.fn(async () => chartResult())); + app.state.varValues = { year: '2024', region: 'us' }; + await renderDashboard(app); + const filters = app.root.querySelector('.dash-filters'); + expect(filters.style.display).not.toBe('none'); + expect([...filters.querySelectorAll('.var-name')].map((n) => n.textContent)).toEqual(['year', 'region']); + expect(fieldInput(app.root, 'year').value).toBe('2024'); + }); + + it('typing debounces before the affected tile(s) re-run; an unaffected tile is untouched', async () => { + vi.useFakeTimers(); + try { + const favorites = [ + paramFav('1', 'SELECT * FROM t WHERE y = {year:UInt16}'), + paramFav('2', 'SELECT * FROM u WHERE y = {year:UInt16}'), + paramFav('3', 'SELECT * FROM v WHERE r = {region:String}'), + ]; + const runTile = vi.fn(async () => chartResult()); + const app = dashApp(favorites, runTile); + app.state.varValues = { year: '2023', region: 'us' }; + await renderDashboard(app); + expect(runTile).toHaveBeenCalledTimes(3); + + setInput(fieldInput(app.root, 'year'), '2024'); + expect(runTile).toHaveBeenCalledTimes(3); // debounced — no re-run yet + await vi.advanceTimersByTimeAsync(499); + expect(runTile).toHaveBeenCalledTimes(3); + await vi.advanceTimersByTimeAsync(1); + expect(runTile).toHaveBeenCalledTimes(5); // only the 2 'year' tiles re-ran + expect(runTile.mock.calls.filter((c) => c[0] === favorites[2].sql)).toHaveLength(1); // region tile untouched + expect(app.state.varValues.year).toBe('2024'); // shared with the workbench's varValues + expect(app.saveVarValues).toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('Enter fires the re-run immediately, bypassing the debounce', async () => { + const favorites = [paramFav('1', 'SELECT * FROM t WHERE y = {year:UInt16}')]; + const runTile = vi.fn(async () => chartResult()); + const app = dashApp(favorites, runTile); + app.state.varValues = { year: '2023' }; + await renderDashboard(app); + expect(runTile).toHaveBeenCalledTimes(1); + const input = fieldInput(app.root, 'year'); + setInput(input, '2024'); + pressEnter(input); + await flush(); + expect(runTile).toHaveBeenCalledTimes(2); + }); + + it('Enter/blur with no pending edit is a no-op (nothing to commit)', async () => { + const favorites = [paramFav('1', 'SELECT * FROM t WHERE y = {year:UInt16}')]; + const runTile = vi.fn(async () => chartResult()); + const app = dashApp(favorites, runTile); + app.state.varValues = { year: '2023' }; + await renderDashboard(app); + expect(runTile).toHaveBeenCalledTimes(1); + const input = fieldInput(app.root, 'year'); + pressEnter(input); // no prior 'input' event — no pending debounce to fire + input.dispatchEvent(new Event('blur', { bubbles: true })); + await flush(); + expect(runTile).toHaveBeenCalledTimes(1); + }); + + it('editing a filter before the dashboard has ever run a tile is a no-op', async () => { + const favorites = [paramFav('1', 'SELECT * FROM t WHERE y = {year:UInt16}')]; + const runTile = vi.fn(async () => chartResult()); + const app = dashApp(favorites, runTile); + app.state.varValues = { year: '2023' }; + app.ensureFreshToken = vi.fn(async () => false); // session can't be refreshed — no slots built + await renderDashboard(app); + expect(runTile).not.toHaveBeenCalled(); + const input = fieldInput(app.root, 'year'); + setInput(input, '2024'); + pressEnter(input); + await flush(); + expect(runTile).not.toHaveBeenCalled(); // still a no-op — nothing to update + }); + + it('blur fires the re-run immediately, bypassing the debounce', async () => { + const favorites = [paramFav('1', 'SELECT * FROM t WHERE y = {year:UInt16}')]; + const runTile = vi.fn(async () => chartResult()); + const app = dashApp(favorites, runTile); + app.state.varValues = { year: '2023' }; + await renderDashboard(app); + expect(runTile).toHaveBeenCalledTimes(1); + const input = fieldInput(app.root, 'year'); + setInput(input, '2024'); + input.dispatchEvent(new Event('blur', { bubbles: true })); + await flush(); + expect(runTile).toHaveBeenCalledTimes(2); + }); + + it('a tile with an unfilled param shows a placeholder and never calls runTile; filling it runs the tile', async () => { + const favorites = [paramFav('1', 'SELECT * FROM t WHERE y = {year:UInt16}')]; + const runTile = vi.fn(async () => chartResult()); + const app = dashApp(favorites, runTile); // no varValues set — 'year' unfilled + await renderDashboard(app); + expect(runTile).not.toHaveBeenCalled(); + const placeholder = app.root.querySelector('.dash-tile-unfilled'); + expect(placeholder.textContent).toBe('Enter a value for: year'); + // An unfilled tile is not counted in the "N not shown" note. + expect(app.root.querySelector('.dash-skip').style.display).toBe('none'); + + const input = fieldInput(app.root, 'year'); + setInput(input, '2024'); + pressEnter(input); + await flush(); + expect(runTile).toHaveBeenCalledTimes(1); + expect(app.root.querySelector('.dash-tile canvas')).not.toBeNull(); + expect(app.root.querySelector('.dash-tile-unfilled')).toBeNull(); + }); + + it('discards a stale response when a newer edit\'s response arrives first (last edit wins)', async () => { + const favorites = [paramFav('1', 'SELECT * FROM t WHERE y = {year:UInt16}')]; + const resolvers = []; + const runTile = vi.fn(() => new Promise((resolve) => resolvers.push(resolve))); + const app = dashApp(favorites, runTile); + app.state.varValues = { year: '2023' }; + const rendered = renderDashboard(app); + await flush(); + expect(resolvers).toHaveLength(1); + resolvers[0](chartResult()); + await rendered; + + const input = fieldInput(app.root, 'year'); + setInput(input, 'A'); + pressEnter(input); + await flush(); + expect(resolvers).toHaveLength(2); + setInput(input, 'B'); + pressEnter(input); + await flush(); + expect(resolvers).toHaveLength(3); + + // The newer edit ('B') resolves first; the superseded ('A') resolves after. + resolvers[2]({ error: 'B wins' }); + await flush(); + resolvers[1]({ error: 'A is stale — must be discarded' }); + await flush(); + + expect(app.root.querySelector('.dash-tile-error').textContent).toBe('B wins'); + }); +}); + // ── app.js: runTile + auth handoff wiring ──────────────────────────────────── function jwt(payload) { const b = (o) => Buffer.from(JSON.stringify(o)).toString('base64url'); @@ -440,6 +674,15 @@ describe('app.runTile', () => { })); expect((await app.runTile('SELECT 1')).error).toMatch(/readonly/); }); + it('substitutes state.varValues as param_ args (#149 D3)', async () => { + const fetch = makeFetch([[(u, sql) => /SELECT/.test(sql || ''), + resp({ json: { meta: [{ name: 'n', type: 'UInt64' }], data: [{ n: 1 }] } })]]); + const app = createApp(appEnv({ fetch })); + app.state.varValues.year = '2024'; + await app.runTile('SELECT {year:UInt16} AS n'); + const queryCall = fetch.mock.calls.find((c) => c[1] && c[1].method === 'POST'); + expect(queryCall[0]).toContain('param_year=2024'); + }); it('errors (without driving sign-out) when there is no token', async () => { const app = createApp(appEnv({ sessionStorage: memSession({}) })); expect(await app.runTile('SELECT 1')).toEqual({ error: 'Not signed in' });