diff --git a/CHANGELOG.md b/CHANGELOG.md index b8b01a5..dad60b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,25 @@ auto-generated per-PR notes; this file is the curated, human-readable history. ## [Unreleased] ### Added +- **Dashboard (phase 1): open your favorited Library queries as a read-only + dashboard in a new tab** (#149). A new **File ▾ → "Open as dashboard"** item + (enabled once at least one query is starred) opens `/sql/dashboard` — the same + single served artifact, reached by a client-side route — and renders each + favorited, chartable query as a live chart tile, reusing the existing Chart.js + result view. The new tab is authenticated by a **one-time, same-origin + `postMessage` credential handoff** from the opener (both the target origin and + the peer window are verified); a cold/bookmarked visit falls back to the normal + login flow, which returns to the dashboard after sign-in. Tile queries run + **read-only** (`readonly=2`), so a favorite that happens to contain a write is + rejected server-side rather than executed on open/refresh. Tiles fetch with a + bounded concurrency (so a large favorites list doesn't stampede the cluster), + the auth token is resolved once before they fan out (no intra-tab refresh + race), and a handed-off-but-expired token is refreshed rather than forcing a + re-login. Single-row (KPI) and non-chartable favorites are skipped for now with + an "N not shown" note. KPI tiles, global filters, drag-to-arrange layout, + per-tile controls, and export arrive in later phases (#149 D2–D7). Known + limitation: two tabs independently refreshing a *rotating* OAuth refresh token + can race (BroadcastChannel sync deferred). - **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/README.md b/README.md index 85190f2..45f7791 100644 --- a/README.md +++ b/README.md @@ -374,7 +374,12 @@ see "Security headers" below), and uploads the SPA + config into ClickHouse 1. Add the rendered `dist/http_handlers.xml` to the server's `config.d/` (or push it as an ACM cluster setting `config.d/sql-browser.xml`) and reload ClickHouse. -2. Register the redirect URI `https:///sql` with your OAuth IdP. + The SPA handler serves both `/sql` (the workbench) and `/sql/dashboard` (the + favorites dashboard) from the same file. +2. Register the redirect URI `https:///sql` with your OAuth IdP. If users + will open `/sql/dashboard` via a cold/bookmarked link (rather than from the app, + which hands credentials over in-session), also register + `https:///sql/dashboard` so that direct sign-in can complete. 3. Make sure ClickHouse accepts the bearer JWT — either a CH `` entry validating your IdP's JWKS, or a delegated `` verifier. See [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md). diff --git a/build/local.py b/build/local.py index f615d11..b5e4662 100644 --- a/build/local.py +++ b/build/local.py @@ -242,7 +242,10 @@ def do_GET(self): if path.endswith("/config.json"): self._send(CONFIG, "application/json; charset=utf-8") return - if path.rstrip("/") in ("", "/sql", "/sql.html"): + # Serve the SPA for the workbench (/sql) and the client-side dashboard + # route (/sql/dashboard) — mirrors the production http_handlers rule so + # "Open as dashboard" works under `npm run local` too (#149 D1). + if path.rstrip("/") in ("", "/sql", "/sql.html", "/sql/dashboard"): try: with open(SPA, "rb") as f: html = f.read() diff --git a/deploy/http_handlers.xml b/deploy/http_handlers.xml index e896aa3..046cab2 100644 --- a/deploy/http_handlers.xml +++ b/deploy/http_handlers.xml @@ -6,6 +6,7 @@ Routes: GET /sql -> the SPA (dist/sql.html in user_files) + GET /sql/dashboard -> the SAME SPA (client-side route: the favorites dashboard) GET /sql/config.json -> the OAuth config (issuer + client_id [+ audience]) The SPA POSTs queries to "/" with `Authorization: Bearer `; that is @@ -23,7 +24,10 @@ - regex:^/sql/?$ + + regex:^/sql(/dashboard)?/?$ GET static diff --git a/src/core/auth-handoff.js b/src/core/auth-handoff.js new file mode 100644 index 0000000..8465e0c --- /dev/null +++ b/src/core/auth-handoff.js @@ -0,0 +1,54 @@ +// Pure helpers for the one-time cross-tab auth handoff (#149 D1). No DOM. +// +// "Open as dashboard" opens a new same-origin tab whose sessionStorage starts +// empty. Rather than force a second sign-in, the opener grants its live +// credentials once via postMessage: the child requests them, the opener replies +// with a snapshot of its auth session keys, and the child restores them into its +// own (per-tab) sessionStorage. Everything here is pure — the postMessage wiring +// + origin/source checks live in the app controller (over injected window seams); +// these are the message contract, the key set, and the origin/source predicates, +// kept here so they are trivially 100% testable. + +/** The sessionStorage keys that carry a live auth session (OAuth or basic). */ +export const AUTH_SS_KEYS = [ + 'oauth_id_token', 'oauth_refresh_token', 'oauth_idp', 'oauth_origin', + 'ch_basic_auth', 'ch_basic_user', 'ch_basic_origin', +]; + +/** postMessage `data.type` values for the handoff handshake. */ +export const AUTH_REQUEST = 'asb-auth-request'; +export const AUTH_GRANT = 'asb-auth-grant'; + +/** Read the present auth keys out of a sessionStorage-like object. */ +export function snapshotAuth(ss) { + const snap = {}; + for (const k of AUTH_SS_KEYS) { + const v = ss.getItem(k); + if (v != null) snap[k] = v; + } + return snap; +} + +/** Write a snapshot's auth keys into a sessionStorage-like object. */ +export function restoreAuth(ss, snap) { + for (const k of AUTH_SS_KEYS) { + if (snap && snap[k] != null) ss.setItem(k, snap[k]); + } +} + +/** Does a snapshot carry usable credentials (an OAuth token or basic creds)? */ +export function hasAuth(snap) { + return !!(snap && (snap.oauth_id_token || snap.ch_basic_auth)); +} + +/** A well-formed credential *request* from the expected origin + source window. */ +export function isAuthRequest(e, origin, source) { + return !!e && e.origin === origin && e.source === source + && !!e.data && e.data.type === AUTH_REQUEST; +} + +/** A well-formed credential *grant* from the expected origin + source window. */ +export function isAuthGrant(e, origin, source) { + return !!e && e.origin === origin && e.source === source + && !!e.data && e.data.type === AUTH_GRANT; +} diff --git a/src/core/dashboard.js b/src/core/dashboard.js new file mode 100644 index 0000000..5ed5ecb --- /dev/null +++ b/src/core/dashboard.js @@ -0,0 +1,85 @@ +// Pure logic for the Dashboard view (#149). No DOM, no globals. +// +// A dashboard is "the favorited subset of the Library, rendered together" — no +// new schema. This module holds the route helpers, the ClickHouse `FORMAT JSON` +// → array-rows transform the chart layer expects, and the per-tile +// classification (chart vs skip). KPI tiles (single-row) and non-chartable +// favorites are skipped in D1 (KPIs arrive in D2); the render layer counts them +// for the header's "N not shown" note. + +import { autoChart, chartCfgValid, cloneChartCfg, normalizeChartCfg } from './chart-data.js'; +import { withTrailingFormat } from './format.js'; + +/** + * True on the standalone dashboard route (a path ending in `/dashboard`, + * trailing slash ok). Matches on the `/dashboard` suffix rather than a pinned + * `/sql/dashboard` so it stays consistent with `configBase` (which strips the + * same suffix) and survives the SPA being mounted somewhere other than `/sql`. + * The server only serves the artifact at its SPA routes, so nothing unexpected + * reaches this predicate. + */ +export function isDashboardRoute(pathname) { + return /\/dashboard\/?$/.test(pathname || ''); +} + +/** + * The SPA base path for config.json / OAuth resolution, independent of the + * dashboard sub-route: `/sql/dashboard` → `/sql` so `loadConfigDoc` fetches + * `/sql/config.json` (not the non-existent `/sql/dashboard/config.json`). + */ +export function configBase(pathname) { + return (pathname || '').replace(/\/dashboard\/?$/, ''); +} + +/** + * A favorite's SQL prepared for a one-shot tile fetch: `FORMAT JSON` appended + * unless the query already ends in its own trailing `FORMAT` clause (which we + * leave intact; a non-JSON format just errors the tile gracefully rather than + * being silently doubled). Delegates to `withTrailingFormat`, which strips a + * trailing `;`/comments and reuses `detectSqlFormat` (handling ClickHouse's + * `FORMAT x SETTINGS y` ordering). Empty input → '' (no favorite is empty). + */ +export function dashboardTileSql(sql) { + return withTrailingFormat(sql, 'JSON').sql; +} + +/** + * Transform a ClickHouse `FORMAT JSON` response into the shape the chart layer + * wants: `columns` = `meta` ([{name,type}]), `rows` = array-of-arrays (row[i] + * by column position), plus a small footer meta ({rows, ms, bytes}). + */ +export function parseJsonResult(json) { + const columns = json.meta || []; + const data = json.data || []; + const rows = data.map((o) => columns.map((c) => o[c.name])); + const stats = json.statistics || {}; + return { + columns, + rows, + meta: { + rows: json.rows != null ? json.rows : rows.length, + ms: stats.elapsed != null ? Math.round(stats.elapsed * 1000) : null, + bytes: stats.bytes_read != null ? stats.bytes_read : null, + }, + }; +} + +/** + * Classify a favorite's result into a dashboard tile. In D1: + * - 0 rows → skip (empty) + * - exactly 1 row → skip (a KPI — rendered in D2) + * - saved chart cfg valid for these columns → chart with that cfg + * - else autoChart → chart, or skip when nothing is plottable + * `savedChart` is the favorite's persisted `{cfg, key}` (or undefined). The + * returned cfg is a normalized clone — never an alias of the saved entry. + */ +export function classifyTile(columns, rows, savedChart) { + if (rows.length === 0) return { kind: 'skip', reason: 'empty' }; + if (rows.length === 1) return { kind: 'skip', reason: 'kpi' }; + const saved = savedChart && savedChart.cfg; + const cfg = chartCfgValid(saved, columns) + ? normalizeChartCfg(cloneChartCfg(saved)) + : autoChart(columns); + if (!cfg) return { kind: 'skip', reason: 'nonChartable' }; + return { kind: 'chart', cfg }; +} diff --git a/src/core/format.js b/src/core/format.js index fb0ae24..0920510 100644 --- a/src/core/format.js +++ b/src/core/format.js @@ -137,20 +137,37 @@ export function detectSqlFormat(sql) { } /** - * Resolve an editor query for a full (uncapped) export. If it already ends in - * a `FORMAT ` clause (detectSqlFormat), the SQL is kept as-is and that - * format is reported; otherwise `FORMAT TabSeparatedWithNames` is appended. A - * trailing `;` is peeled either way (FORMAT must be the last clause). Empty - * input → `{ sql: '', format: 'TabSeparatedWithNames' }` — the caller no-ops - * on an empty `sql`. Pure. + * Peel a trailing `;` and any trailing SQL comments (line `-- …` / block + * `/* … *​/`) from `sql`, then resolve its output format: if what remains already + * ends in a `FORMAT ` clause (detectSqlFormat) that format is kept and + * reported; otherwise `fallbackFormat` is appended. Comments are peeled *before* + * the check so a `… FORMAT JSON -- note` isn't mis-read as unformatted (which + * would double the FORMAT) and so an appended clause lands after real SQL rather + * than after a line comment that would swallow it. Empty input → `{ sql: '', + * format: fallbackFormat }` (nothing is appended to an empty query). Pure — + * shared by the export prep and the dashboard tile fetch so this edge handling + * lives in one place. + */ +export function withTrailingFormat(sql, fallbackFormat) { + let s = String(sql || '').trim().replace(/;+\s*$/, '').trim(); + let prev; + do { + prev = s; + s = s.replace(/--[^\n]*$/, '').replace(/\/\*[\s\S]*?\*\/\s*$/, '').trim(); + } while (s !== prev); + const fmt = detectSqlFormat(s); + if (fmt) return { sql: s, format: fmt }; + return { sql: s ? s + '\nFORMAT ' + fallbackFormat : s, format: fallbackFormat }; +} + +/** + * Resolve an editor query for a full (uncapped) export: its own trailing + * `FORMAT`, or `FORMAT TabSeparatedWithNames`. See `withTrailingFormat`. Empty + * input → `{ sql: '', format: 'TabSeparatedWithNames' }` — the caller no-ops on + * an empty `sql`. Pure. */ export function prepareExportSql(sql) { - const s = String(sql || '').trim().replace(/;+\s*$/, '').trim(); - if (!s) return { sql: '', format: 'TabSeparatedWithNames' }; - const fmt = detectSqlFormat(s); - return fmt - ? { sql: s, format: fmt } - : { sql: s + '\nFORMAT TabSeparatedWithNames', format: 'TabSeparatedWithNames' }; + return withTrailingFormat(sql, 'TabSeparatedWithNames'); } const SCHEMA_MUTATING_RE = /^(CREATE|DROP|ALTER|RENAME|TRUNCATE|ATTACH|DETACH|EXCHANGE)\b/i; diff --git a/src/main.js b/src/main.js index d9a859d..e5e772d 100644 --- a/src/main.js +++ b/src/main.js @@ -11,11 +11,16 @@ import { handleKeydown } from './ui/shortcuts.js'; import { exchangeCodeForTokens, bearerFromTokens } from './net/oauth.js'; import { decodeShare } from './core/share.js'; import { cloneChartCfg } from './core/chart-data.js'; +import { isDashboardRoute } from './core/dashboard.js'; export async function bootstrap(app, env) { const loc = env.location; const ss = env.sessionStorage; const hist = env.history; + // The standalone dashboard route (#149) reuses this same bootstrap + app: it + // shares the OAuth-callback handling below, but renders the dashboard instead + // of the workbench and skips editor-only share-link seeding. + const dash = isDashboardRoute(loc.pathname); const u = new URL(loc.href); const code = u.searchParams.get('code'); const stateParam = u.searchParams.get('state'); @@ -55,21 +60,36 @@ export async function bootstrap(app, env) { // A shared query (SQL + chart config) rides in the URL hash, which is lost // through the OAuth redirect (and we strip it below). Stash it in // sessionStorage so it survives the round-trip and restore it once we're back. - let shared = decodeShare(loc.hash); - if (shared.sql) ss.setItem('oauth_shared', JSON.stringify(shared)); - else { - try { shared = JSON.parse(ss.getItem('oauth_shared') || 'null') || { sql: '', chart: null }; } - catch { shared = { sql: '', chart: null }; } - } - if (shared.sql) { - const t0 = app.state.tabs.value[0]; - t0.sql = shared.sql; - t0.name = 'Shared query'; - if (shared.chart && shared.chart.cfg) { - t0.chartCfg = cloneChartCfg(shared.chart.cfg); - t0.chartKey = shared.chart.key ?? null; + // The dashboard route has no editor tab to seed, so it skips this entirely. + if (!dash) { + let shared = decodeShare(loc.hash); + if (shared.sql) ss.setItem('oauth_shared', JSON.stringify(shared)); + else { + try { shared = JSON.parse(ss.getItem('oauth_shared') || 'null') || { sql: '', chart: null }; } + catch { shared = { sql: '', chart: null }; } + } + if (shared.sql) { + const t0 = app.state.tabs.value[0]; + t0.sql = shared.sql; + t0.name = 'Shared query'; + if (shared.chart && shared.chart.cfg) { + t0.chartCfg = cloneChartCfg(shared.chart.cfg); + t0.chartKey = shared.chart.key ?? null; + } + hist.replaceState(null, '', loc.pathname + loc.search); } - hist.replaceState(null, '', loc.pathname + loc.search); + } + + // A freshly-opened dashboard tab is signed out (per-tab sessionStorage); try a + // one-time credential handoff from the opener before deciding what to render. + // A cold/bookmarked visit has no opener → falls through to the login screen, + // which after sign-in returns to /sql/dashboard and renders the dashboard. + if (dash && !app.isSignedIn()) { + await app.receiveAuthHandoff(env); + // The opener may hand over an *expired* id_token whose refresh token is still + // good (an idle opener refreshes only lazily). Attempt a refresh before + // giving up — otherwise a valid handoff would still bounce to a full re-login. + if (!app.isSignedIn()) await app.ensureFreshToken(); } if (app.isSignedIn()) { @@ -79,7 +99,7 @@ export async function bootstrap(app, env) { // ch_auth=basic username, not the raw email claim) on first paint. // (ensureConfig is a no-op in basic mode.) await app.ensureConfig(); - app.renderApp(); + if (dash) app.renderDashboard(); else app.renderApp(); } else { app.showLogin(callbackError); } @@ -95,6 +115,7 @@ if (typeof document !== 'undefined' && !globalThis.__ASB_NO_AUTOSTART__) { sessionStorage: window.sessionStorage, history: window.history, fetch: window.fetch.bind(window), + opener: window.opener, // dashboard tab reads its opener for the auth handoff }); } /* c8 ignore stop */ diff --git a/src/net/ch-client.js b/src/net/ch-client.js index ff6a962..8857195 100644 --- a/src/net/ch-client.js +++ b/src/net/ch-client.js @@ -77,13 +77,28 @@ 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. */ -export async function queryJson(ctx, sql, signal) { - const resp = await authedFetch(ctx, chUrl(ctx.origin, { format: 'JSON' }), 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). + */ +export async function queryJson(ctx, sql, signal, extra) { + const resp = await authedFetch(ctx, chUrl(ctx.origin, { format: 'JSON', extra }), sql, signal); if (!resp.ok) throw new Error(parseExceptionText(await resp.text())); return resp.json(); } +/** + * Run a favorite's SQL for a read-only dashboard tile (#149): `FORMAT JSON` plus + * 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. + */ +export function queryDashboardTile(ctx, sql, signal) { + return queryJson(ctx, sql, signal, { readonly: 2 }); +} + /** * Run a `system.tables`/`system.columns` query (`sqlBody`, without its FORMAT * clause) with data-lake-catalog visibility enabled, falling back to the plain diff --git a/src/styles.css b/src/styles.css index f7baab7..ba3e2f6 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1737,3 +1737,89 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } /* Cell-detail drawer → full-width, non-resizable (its handle is hidden above). */ .cd-panel { width: 100vw !important; min-width: 0; } } + +/* ── Dashboard (#149 D1) ─────────────────────────────────────────────────── + The standalone /sql/dashboard page: sticky header + a responsive grid of + read-only chart tiles rendered from favorited Library queries. */ +/* The dashboard's own scroll container: #root is a fixed overflow:hidden flex + column (the workbench shell), so the dashboard fills it and scrolls itself. */ +.dash-page { height: 100%; overflow-y: auto; overflow-x: hidden; background: var(--bg); } +.dash-header { + position: sticky; top: 0; z-index: 40; display: flex; align-items: center; + gap: 12px; padding: 11px 20px; background: var(--bg-header); + border-bottom: 1px solid var(--border); 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); + border-radius: 7px; cursor: pointer; +} +.dash-icobtn:hover { background: var(--bg-hover); color: var(--fg); } +.dash-back { + display: inline-flex; align-items: center; gap: 6px; color: var(--fg-mute); + text-decoration: none; font-size: 12px; padding: 4px 9px; border-radius: 6px; + border: 1px solid var(--border); +} +.dash-back:hover { background: var(--bg-hover); color: var(--fg); } +.dash-back svg { transform: scaleX(-1); } +.dash-title { font-size: 16px; font-weight: 700; color: var(--fg); letter-spacing: -.01em; } +.dash-chip { + display: inline-flex; align-items: center; gap: 5px; font-size: 11px; + color: var(--fg-mute); background: var(--bg-chip); padding: 3px 9px; border-radius: 20px; +} +.dash-fav { color: var(--accent); } +.dash-src { font-family: var(--mono); } +.dash-dot { width: 6px; height: 6px; border-radius: 6px; background: #22C55E; } +.dash-skip { font-size: 11px; color: var(--fg-faint); } +.dash-updated { font-size: 11px; color: var(--fg-faint); font-family: var(--mono); } +.dash-btn { + display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 12px; + background: var(--bg-chip); color: var(--fg); border: 1px solid var(--border); + border-radius: 7px; font: 500 12px var(--ui); cursor: pointer; +} +.dash-btn:hover { background: var(--bg-hover); } +.dash-btn:disabled { opacity: .55; cursor: default; } +/* Arrange grid: 3 across on wide screens (the design's default), degrading to + 2 then 1 as width shrinks. A configurable 2/3-column switcher + drag-reorder + land in D5. */ +.dash-grid { + display: grid; gap: 14px; padding: 18px 20px 40px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + max-width: 1560px; margin: 0 auto; +} +@media (max-width: 1100px) { .dash-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +@media (max-width: 720px) { .dash-grid { grid-template-columns: 1fr; } } +/* D1 shows charts read-only: renderChart is called with controls:false so the + Type/X/Y config bar isn't built at all (a settings-popover arrives in D6). */ +.dash-empty { padding: 60px 20px; text-align: center; color: var(--fg-mute); font-size: 13px; } +.dash-tile { + display: flex; flex-direction: column; overflow: hidden; min-height: 300px; + background: var(--bg-side); border: 1px solid var(--border); border-radius: 10px; +} +.dash-tile-head { + padding: 11px 12px 9px; border-bottom: 1px solid var(--border-faint); +} +.dash-tile-name { + font-size: 13px; font-weight: 600; color: var(--fg); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; +} +.dash-tile-body { flex: 1; min-height: 0; padding: 6px 8px; display: flex; } +.dash-tile-body > .chart-view { flex: 1; min-width: 0; } +.dash-tile-load { + display: flex; align-items: center; gap: 8px; margin: auto; + color: var(--fg-faint); font-size: 12px; +} +.dash-tile-error { + margin: auto; padding: 12px; color: var(--error-fg); background: var(--error-bg); + border: 1px solid var(--error-bd); border-radius: 8px; font-size: 12px; + font-family: var(--mono); max-width: 90%; +} +.dash-tile-foot { + display: flex; align-items: center; gap: 12px; padding: 7px 12px; + border-top: 1px solid var(--border-faint); font-family: var(--mono); + font-size: 10.5px; color: var(--fg-faint); +} +@media (max-width: 640px) { + .dash-grid { grid-template-columns: 1fr; padding: 12px; } + .dash-header { padding: 10px 12px; gap: 8px; } +} diff --git a/src/ui/app.js b/src/ui/app.js index 9d2186c..e04817c 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -26,6 +26,8 @@ import { encodeShare } from '../core/share.js'; import { assembleReferenceData, buildCompletions } from '../core/completions.js'; import { generatePKCE, randomState } from '../core/pkce.js'; import { viewportZoom } from '../core/zoom-support.js'; +import { configBase, dashboardTileSql, parseJsonResult } from '../core/dashboard.js'; +import { snapshotAuth, restoreAuth, hasAuth, isAuthRequest, isAuthGrant, AUTH_REQUEST, AUTH_GRANT } from '../core/auth-handoff.js'; import * as oauthCfg from '../net/oauth-config.js'; import * as oauth from '../net/oauth.js'; import * as ch from '../net/ch-client.js'; @@ -35,6 +37,7 @@ import { renderTabs, selectTab, newTab, closeTab, loadIntoNewTab } from './tabs. import { effect, batch } from '@preact/signals-core'; import { renderSchema } from './schema.js'; import { renderResults } from './results.js'; +import { renderDashboard } from './dashboard.js'; import { openSchemaView } from './explain-graph.js'; import { openDetailPane } from './schema-detail.js'; import { renderSavedHistory } from './saved-history.js'; @@ -117,7 +120,13 @@ export function createApp(env = {}) { // config.json may list several IdPs. Fetch the doc once; resolve OIDC // discovery per selected IdP. The chosen IdP id is persisted so it survives // the OAuth redirect (like oauth_state) and drives token exchange/refresh. - const loadDoc = oauthCfg.memoizeConfig(() => oauthCfg.loadConfigDoc(fetchFn, loc.pathname)); + // configBase strips a trailing `/dashboard` so config.json / OAuth discovery + // resolve from the SPA base (`/sql/config.json`) on the dashboard route too. + // The same base is the single source of truth for the workbench↔dashboard + // links (openDashboard, the dashboard's Back link) rather than hardcoding + // `/sql` in several shapes. + app.basePath = configBase(loc.pathname); + const loadDoc = oauthCfg.memoizeConfig(() => oauthCfg.loadConfigDoc(fetchFn, app.basePath)); const resolvedCache = new Map(); app.idpId = ss.getItem('oauth_idp') || null; function selectIdp(id) { app.idpId = id; ss.setItem('oauth_idp', id); } @@ -1623,6 +1632,115 @@ export function createApp(env = {}) { // that changes the editor content; a no-op on desktop. const toEditorOnMobile = () => { if (app.state.isMobile.value) app.state.mobileView.value = 'editor'; }; + // --- dashboard (#149 D1) ---------------------------------------------- + // ensureConfig + getToken, resolving (and refreshing) the auth token ONCE. + // The dashboard calls this before fanning tiles out, so the tiles never each + // race an expired-token refresh (a rotating refresh token used N-ways at once + // would invalidate itself), and a single sign-out is handled by the caller + // instead of N tiles each firing onSignedOut. Also used by bootstrap to + // refresh a handed-off-but-expired token before falling back to login. + async function ensureFreshToken() { + await ensureConfig(); + return !!(await getToken()); + } + app.ensureFreshToken = ensureFreshToken; + + // 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. + async function runTile(sql) { + try { + // ensureConfig + getToken are inside the try: getToken→refresh can THROW on + // a network/IdP failure, and a tile must degrade to { error } rather than + // reject (a rejected tile would break the whole grid's Promise.all). + // ensureConfig is memoized, so calling it here and in ensureFreshToken is + // 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)); + return parseJsonResult(json); + } catch (e) { + return { error: String((e && e.message) || e) }; + } + } + app.runTile = runTile; + app.renderDashboard = () => renderDashboard(app); + + // One-time cross-tab auth handoff. The dashboard opens in a new same-origin + // tab whose sessionStorage starts empty; rather than force a second sign-in, + // this (opener) tab grants its live credentials once when the child asks. + // Both sides pin the target origin AND the peer window; a timeout stops the + // opener listening if the child never asks. Message contract: core/auth-handoff. + // Two windows: the child waits HANDOFF_MS for a grant once it *asks* (a + // same-origin reply is near-instant, so this is short); the opener listens far + // longer (HANDOFF_LISTEN_MS) because it must survive the child's cold JS load + // before the child can ask — a short opener window would drop a slow tab's + // request and force a needless re-login. + const HANDOFF_MS = env.handoffMs != null ? env.handoffMs : 4000; + const HANDOFF_LISTEN_MS = env.handoffListenMs != null ? env.handoffListenMs : 30000; + function sendAuthHandoff(child) { + const onMsg = (e) => { + if (!isAuthRequest(e, loc.origin, child)) return; + const creds = snapshotAuth(ss); + // Only grant when we actually hold credentials — never hand over an empty + // snapshot (which the child would have to reject anyway). + if (hasAuth(creds)) child.postMessage({ type: AUTH_GRANT, creds }, loc.origin); + win.removeEventListener('message', onMsg); + }; + win.addEventListener('message', onMsg); + win.setTimeout(() => win.removeEventListener('message', onMsg), HANDOFF_LISTEN_MS); + } + // Open the dashboard in a new tab and stand ready to hand it our credentials. + function openDashboard() { + const child = app.openWindow(loc.origin + app.basePath + '/dashboard'); + if (child) sendAuthHandoff(child); + } + app.openDashboard = openDashboard; + + // Restore a handed-off credential snapshot into BOTH this tab's sessionStorage + // and the already-constructed in-memory auth fields — token/authMode/idp/origin + // were snapshotted from an empty ss at construction, so writing keys back alone + // wouldn't take effect until a reload. + function applyAuthSnapshot(creds) { + restoreAuth(ss, creds); + if (creds.ch_basic_auth) { + app.authMode = 'basic'; + chCtx.origin = creds.ch_basic_origin || loc.origin; + } else { + if (creds.oauth_id_token) setTokens(creds.oauth_id_token, creds.oauth_refresh_token); + if (creds.oauth_idp) app.idpId = creds.oauth_idp; + chCtx.origin = creds.oauth_origin || loc.origin; + } + } + // Child side: ask the opener for credentials once. Resolves true once a valid + // grant is applied; false when there's no opener or the request times out (a + // cold/bookmarked visit → the caller falls through to the normal login flow). + app.receiveAuthHandoff = (handoffEnv) => new Promise((resolve) => { + const opener = handoffEnv.opener; + if (!opener) { resolve(false); return; } + let done = false; + const finish = (ok) => { + if (done) return; + done = true; + win.removeEventListener('message', onMsg); + resolve(ok); + }; + const onMsg = (e) => { + if (!isAuthGrant(e, loc.origin, opener)) return; + // Ignore an empty grant (opener signed out / mid-sign-in) — keep waiting so + // the request times out into the normal login rather than falsely + // reporting success with no credentials applied. + if (!hasAuth(e.data.creds)) return; + applyAuthSnapshot(e.data.creds); + finish(true); + }; + win.addEventListener('message', onMsg); + opener.postMessage({ type: AUTH_REQUEST }, loc.origin); + win.setTimeout(() => finish(false), HANDOFF_MS); + }); + // --- actions registry -------------------------------------------------- app.actions = { run: runEntry, @@ -1652,6 +1770,7 @@ export function createApp(env = {}) { openNodeDetail, insertCreate: async (target) => { await insertCreate(target); toEditorOnMobile(); }, openShortcuts: () => openShortcuts(app), + openDashboard, // Editor-mutating actions jump the mobile bottom-nav to the Editor panel // (#126) so a schema tap / SHOW CREATE lands where the user can see it. insertAtCursor: (text) => { app.editor.insertAtCursor(text); toEditorOnMobile(); }, diff --git a/src/ui/dashboard.js b/src/ui/dashboard.js new file mode 100644 index 0000000..4c26035 --- /dev/null +++ b/src/ui/dashboard.js @@ -0,0 +1,168 @@ +// The standalone read-only Dashboard page (#149 D1). 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). + +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 { formatBytes, formatRows } from '../core/format.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; + +/** Build a tile's footer meta row (rows · ms · bytes), omitting stats CH didn't return. */ +function tileFooter(meta) { + const parts = [h('span', null, formatRows(meta.rows) + ' rows')]; + if (meta.ms != null) parts.push(h('span', null, meta.ms + ' ms')); + if (meta.bytes != null) parts.push(h('span', null, formatBytes(meta.bytes) + ' scanned')); + return parts; +} + +/** + * Bounded-concurrency map that preserves append order. Workers grab the next + * index in turn; each `worker` appends its card synchronously before its first + * await, so cards land in favorite order regardless of which query returns + * first. Returns the per-item results in index order. + */ +async function runPool(items, limit, worker) { + const results = new Array(items.length); + let next = 0; + const run = async () => { + while (next < items.length) { + const i = next++; + results[i] = await worker(items[i], i); + } + }; + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => run())); + 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…'))); + const foot = h('div', { class: 'dash-tile-foot' }); + const card = h('div', { class: 'dash-tile' }, + h('div', { class: 'dash-tile-head' }, h('span', { class: 'dash-tile-name', title: q.name }, q.name)), + body, foot); + grid.appendChild(card); + + const r = await app.runTile(q.sql); + if (r.error != null) { + body.replaceChildren(h('div', { class: 'dash-tile-error' }, r.error)); + return 'error'; + } + const cls = classifyTile(r.columns, r.rows, q.chart); + if (cls.kind === 'skip') { card.remove(); return 'skip'; } + + // 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. + 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, { + tab: chartTab, setChart: (c) => { inst = c; }, running: false, controls: false, + })); + tiles.push({ destroy: () => inst.destroy() }); + foot.replaceChildren(...tileFooter(r.meta)); + return 'chart'; +} + +/** Render the dashboard into `app.root`. */ +export function renderDashboard(app) { + const { document: doc, state } = app; + doc.documentElement.setAttribute('data-theme', state.theme); + doc.documentElement.setAttribute('data-density', state.density); + app.dom = {}; + + const favorites = state.savedQueries.filter((q) => q.favorite); + + const favChip = h('span', { class: 'dash-chip dash-fav' }, + Icon.star(true), + h('span', null, favorites.length + (favorites.length === 1 ? ' favorite' : ' favorites'))); + const skipNote = h('span', { class: 'dash-skip', style: { display: 'none' } }); + const updated = h('span', { class: 'dash-updated' }); + const refreshBtn = h('button', { class: 'dash-btn', title: 'Re-run all tiles' }, + Icon.refresh(), h('span', null, 'Refresh')); + // Theme toggle, mirroring the workbench header: reuse app.toggleTheme (persists + // the pref + flips data-theme), and register the button as app.dom.themeBtn so + // that helper repaints its icon on toggle. + const themeBtn = h('button', { class: 'dash-icobtn', title: 'Toggle theme', onclick: () => app.toggleTheme() }); + themeBtn.appendChild(state.theme === 'dark' ? Icon.sun() : Icon.moon()); + app.dom.themeBtn = themeBtn; + + const header = h('div', { class: 'dash-header' }, + h('a', { class: 'dash-back', href: app.basePath || '/sql', title: 'Back to SQL Browser' }, + Icon.arrow(), h('span', null, 'SQL Browser')), + h('div', { class: 'dash-title' }, state.libraryName.value), + favChip, + skipNote, + h('div', { class: 'dash-spacer', style: { flex: '1' } }), + h('span', { class: 'dash-chip dash-src', title: app.host() }, + h('span', { class: 'dash-dot' }), app.host()), + updated, + themeBtn, + refreshBtn); + + const grid = h('div', { class: 'dash-grid' }); + 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.'); + + // #root is a fixed, overflow:hidden flex column (the workbench layout), so the + // dashboard needs its own scroll container — otherwise a tall grid clips with + // no vertical scroll. The sticky header lives inside it. + app.root.replaceChildren(h('div', { class: 'dash-page' }, header, 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 = []; + + const refresh = 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; + // 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). + try { + const outcomes = await runPool(favorites, TILE_CONCURRENCY, (q) => renderTile(app, q, grid, liveTiles)); + skipped = outcomes.filter((o) => o === 'skip').length; + } 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(); +} diff --git a/src/ui/file-menu.js b/src/ui/file-menu.js index c5228fb..6f619c0 100644 --- a/src/ui/file-menu.js +++ b/src/ui/file-menu.js @@ -84,11 +84,22 @@ export function openFileMenu(app) { meta ? h('span', { class: 'fm-meta' }, meta) : null); const sep = () => h('div', { class: 'fm-sep' }); const empty = list.length === 0; + const hasFav = list.some((q) => q.favorite); const newLibraryItem = item(Icon.plus(), 'New Library', null, () => { close(); newLibraryAction(app); }); + // Open the favorited subset of the Library as a standalone dashboard (#149). + // Enabled only when at least one query is starred; otherwise it explains why. + const dashboardItem = item(Icon.layers(), 'Open as dashboard', hasFav ? null : 'no favorites', () => { + close(); + if (!hasFav) { flashToast('Star a query to add it to the dashboard', { document: app.document }); return; } + app.actions.openDashboard(); + }); const menu = h('div', { class: 'file-menu' }, newLibraryItem, sep(), + h('div', { class: 'fm-section' }, 'Dashboard'), + dashboardItem, + sep(), h('div', { class: 'fm-section' }, 'Save library'), item(Icon.download(), 'Save JSON', '.json', () => { close(); saveJsonAction(app); }), sep(), diff --git a/src/ui/icons.js b/src/ui/icons.js index 208a2ab..a654262 100644 --- a/src/ui/icons.js +++ b/src/ui/icons.js @@ -71,6 +71,7 @@ export const Icon = { eyeOff: () => iconEl('', 14, 14, 1.4), server: () => iconEl('', 12, 12, 1.3), arrow: () => svg('M2 6h7.5M7 3.5L9.5 6 7 8.5', 12, 12, { stroke: 1.6 }), + refresh: () => svg('M10.5 6a4.5 4.5 0 1 1-1.3-3.2M10.5 1.5V4H8', 12, 12, { stroke: 1.5 }), // Same glyph as the JSON view tab so the Format button's { } matches it. braces: () => svg('M4 1.5C2.5 1.5 2.5 3 2.5 4S2.5 5 1.5 6c1 1 1 2 1 2s0 1.5 1.5 1.5M8 1.5c1.5 0 1.5 1.5 1.5 2.5s0 1 1 2c-1 1-1 2-1 2s0 1.5-1.5 1.5', 12, 12), // EXPLAIN button + Explain view: an indented plan-tree of lines. diff --git a/src/ui/results.js b/src/ui/results.js index 7c37156..29b047d 100644 --- a/src/ui/results.js +++ b/src/ui/results.js @@ -961,6 +961,7 @@ export function installChartZoomFix(chart, canvas) { * slot instead, or closing one view's chart would tear down another's). * `opts.running` overrides the run-state gate — a detached snapshot's `r` is * always already-complete, independent of whatever the live tab is doing. + * `opts.controls === false` omits the Type/X/Y config bar (read-only tiles). */ export function renderChart(app, r, opts = {}) { const tab = opts.tab || app.activeTab(); @@ -974,35 +975,41 @@ export function renderChart(app, r, opts = {}) { const cfg = chartCfgFor(tab, r.columns); if (!cfg) return chartEmpty(Icon.chart(), 'These results aren’t chartable — add a numeric column to plot them.'); - const f = chartFieldOptions(r.columns, cfg); + // `opts.controls === false` omits the interactive Type/X/Y config bar entirely + // (the read-only dashboard tile — #149): the chart draws, but no field controls + // are built, rather than building them and hiding them with CSS. + let bar = null; + if (opts.controls !== false) { + const f = chartFieldOptions(r.columns, cfg); - // Each handler mutates the shared cfg (= tab.chartCfg) and re-renders; - // chartCfgFor folds the cross-field invariants (pie → single measure, - // series ≠ X) on the way back in, so the handlers don't normalize themselves. - const bar = h('div', { class: 'chart-config' }); - bar.appendChild(chartSelect('Type', cfg.type, f.typeOptions, (v) => { cfg.type = v; rerender(); })); - bar.appendChild(chartSelect('X', String(cfg.x), f.xOptions, (v) => { cfg.x = Number(v); rerender(); })); - bar.appendChild(chartSelect('Y', String(cfg.y[0]), f.yOptions, (v) => { cfg.y = [Number(v)]; rerender(); })); - if (f.showMulti) { - bar.appendChild(h('button', { - class: 'chart-toggle', title: 'Plot every numeric column as its own series', - onclick: () => { cfg.y = f.multiActive ? [cfg.y[0]] : f.allMeasures; rerender(); }, - }, f.multiActive ? 'Single series' : 'All measures')); - } - if (f.showSeries) { - bar.appendChild(chartSelect('Series', String(cfg.series ?? ''), f.seriesOptions, (v) => { - cfg.series = v === '' ? null : Number(v); - rerender(); - })); - } - // The chart plots at most cap points for the current type; say so when the - // result is bigger (the table still shows everything) — no silent - // truncation. Recomputed on every rerender (the Type select's onChange), - // so switching type re-slices and updates the note in lockstep. - const cap = chartRowCap(cfg.type); - if (r.rows.length > cap) { - bar.appendChild(h('span', { class: 'chart-cap-note' }, - 'first ' + cap + ' of ' + formatRows(r.rows.length) + ' rows')); + // Each handler mutates the shared cfg (= tab.chartCfg) and re-renders; + // chartCfgFor folds the cross-field invariants (pie → single measure, + // series ≠ X) on the way back in, so the handlers don't normalize themselves. + bar = h('div', { class: 'chart-config' }); + bar.appendChild(chartSelect('Type', cfg.type, f.typeOptions, (v) => { cfg.type = v; rerender(); })); + bar.appendChild(chartSelect('X', String(cfg.x), f.xOptions, (v) => { cfg.x = Number(v); rerender(); })); + bar.appendChild(chartSelect('Y', String(cfg.y[0]), f.yOptions, (v) => { cfg.y = [Number(v)]; rerender(); })); + if (f.showMulti) { + bar.appendChild(h('button', { + class: 'chart-toggle', title: 'Plot every numeric column as its own series', + onclick: () => { cfg.y = f.multiActive ? [cfg.y[0]] : f.allMeasures; rerender(); }, + }, f.multiActive ? 'Single series' : 'All measures')); + } + if (f.showSeries) { + bar.appendChild(chartSelect('Series', String(cfg.series ?? ''), f.seriesOptions, (v) => { + cfg.series = v === '' ? null : Number(v); + rerender(); + })); + } + // The chart plots at most cap points for the current type; say so when the + // result is bigger (the table still shows everything) — no silent + // truncation. Recomputed on every rerender (the Type select's onChange), + // so switching type re-slices and updates the note in lockstep. + const cap = chartRowCap(cfg.type); + if (r.rows.length > cap) { + bar.appendChild(h('span', { class: 'chart-cap-note' }, + 'first ' + cap + ' of ' + formatRows(r.rows.length) + ' rows')); + } } const canvas = h('canvas', null); // via h() so it lands in the right document (detached-tab safe) diff --git a/tests/helpers/fake-app.js b/tests/helpers/fake-app.js index 99533c9..693642a 100644 --- a/tests/helpers/fake-app.js +++ b/tests/helpers/fake-app.js @@ -40,6 +40,12 @@ export function makeApp(over = {}) { activeTab: () => activeTab(state), editor: createNoopPort(), // render modules call the port unconditionally (#143) isSignedIn: () => true, + // Dashboard (#149) surface: auth is resolved once before tiles fan out, the + // Back link derives from the SPA base, and onSignedOut redirects on failure. + ensureFreshToken: vi.fn(async () => true), + chCtx: { onSignedOut: vi.fn() }, + basePath: '/sql', + toggleTheme: vi.fn(), email: () => 'me@example.com', savePref: vi.fn(), saveJSON: vi.fn(), diff --git a/tests/unit/ch-client.test.js b/tests/unit/ch-client.test.js index a8dba1c..a9932ea 100644 --- a/tests/unit/ch-client.test.js +++ b/tests/unit/ch-client.test.js @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { - chUrl, authedFetch, queryJson, loadServerVersion, loadSchema, loadColumns, loadReferenceData, loadEntityDoc, runQuery, killQuery, exportQuery, loadSchemaLineage, loadSchemaCards, loadLineageTransitive, loadTableDetail, AST_PROGRESSIVE_THRESHOLD, + chUrl, authedFetch, queryJson, queryDashboardTile, loadServerVersion, loadSchema, loadColumns, loadReferenceData, loadEntityDoc, runQuery, killQuery, exportQuery, loadSchemaLineage, loadSchemaCards, loadLineageTransitive, loadTableDetail, AST_PROGRESSIVE_THRESHOLD, } from '../../src/net/ch-client.js'; import { sqlString } from '../../src/core/format.js'; @@ -56,6 +56,21 @@ describe('chUrl', () => { }); }); +describe('queryDashboardTile', () => { + it('runs read-only (readonly=2) + FORMAT JSON and returns parsed JSON', async () => { + const ctx = ctxWith(async () => jsonResp({ meta: [{ name: 'n', type: 'UInt64' }], data: [{ n: 1 }] })); + const out = await queryDashboardTile(ctx, 'SELECT 1 AS n\nFORMAT JSON'); + expect(out.data).toEqual([{ n: 1 }]); + const url = ctx.fetch.mock.calls[0][0]; + expect(url).toContain('default_format=JSON'); + expect(url).toContain('readonly=2'); + }); + it('throws CH reason on a non-ok response', async () => { + 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/); + }); +}); + describe('authedFetch', () => { it('throws + signals out when no token', async () => { const ctx = ctxWith(() => jsonResp({}), { getToken: async () => null }); diff --git a/tests/unit/dashboard.test.js b/tests/unit/dashboard.test.js new file mode 100644 index 0000000..95de434 --- /dev/null +++ b/tests/unit/dashboard.test.js @@ -0,0 +1,450 @@ +import { describe, it, expect, vi } from 'vitest'; +import { webcrypto } from 'node:crypto'; +import { + isDashboardRoute, configBase, dashboardTileSql, parseJsonResult, classifyTile, +} from '../../src/core/dashboard.js'; +import { + AUTH_SS_KEYS, AUTH_REQUEST, AUTH_GRANT, + snapshotAuth, restoreAuth, hasAuth, isAuthRequest, isAuthGrant, +} from '../../src/core/auth-handoff.js'; +import { renderDashboard } from '../../src/ui/dashboard.js'; +import { makeApp, FakeChart } from '../helpers/fake-app.js'; +import { createApp } from '../../src/ui/app.js'; +import { createCodeMirrorEditor } from '../../src/editor/codemirror-adapter.js'; + +// ── core/dashboard.js ─────────────────────────────────────────────────────── +describe('isDashboardRoute', () => { + it('matches the dashboard path (with or without a trailing slash), nothing else', () => { + expect(isDashboardRoute('/sql/dashboard')).toBe(true); + expect(isDashboardRoute('/sql/dashboard/')).toBe(true); + expect(isDashboardRoute('/tools/sql/dashboard')).toBe(true); // mount-agnostic (matches configBase) + expect(isDashboardRoute('/sql')).toBe(false); + expect(isDashboardRoute('/sql/config.json')).toBe(false); + expect(isDashboardRoute(undefined)).toBe(false); + }); +}); + +describe('configBase', () => { + it('strips a trailing /dashboard so config resolves from the SPA base', () => { + expect(configBase('/sql/dashboard')).toBe('/sql'); + expect(configBase('/sql/dashboard/')).toBe('/sql'); + expect(configBase('/sql')).toBe('/sql'); + expect(configBase(undefined)).toBe(''); + }); +}); + +describe('dashboardTileSql', () => { + it('strips a trailing ; and appends FORMAT JSON', () => { + expect(dashboardTileSql('SELECT 1;')).toBe('SELECT 1\nFORMAT JSON'); + expect(dashboardTileSql('SELECT 1')).toBe('SELECT 1\nFORMAT JSON'); + }); + it('leaves an explicit FORMAT clause intact (no double FORMAT)', () => { + expect(dashboardTileSql('SELECT 1 FORMAT CSV')).toBe('SELECT 1 FORMAT CSV'); + expect(dashboardTileSql('SELECT 1 FORMAT JSON;')).toBe('SELECT 1 FORMAT JSON'); + // FORMAT followed by SETTINGS (either-order clause) must still count as trailing. + expect(dashboardTileSql('SELECT 1 FORMAT JSON SETTINGS max_threads=1')) + .toBe('SELECT 1 FORMAT JSON SETTINGS max_threads=1'); + }); + it('peels a trailing comment so an existing FORMAT is not doubled', () => { + expect(dashboardTileSql('SELECT 1 FORMAT JSON -- daily')).toBe('SELECT 1 FORMAT JSON'); + expect(dashboardTileSql('SELECT 1 /* note */')).toBe('SELECT 1\nFORMAT JSON'); + }); + it('is defensive about empty/absent SQL (empty in → empty out)', () => { + expect(dashboardTileSql('')).toBe(''); + expect(dashboardTileSql(undefined)).toBe(''); + }); +}); + +describe('parseJsonResult', () => { + it('transforms a full FORMAT JSON response into columns + array rows + meta', () => { + const out = parseJsonResult({ + meta: [{ name: 'k', type: 'String' }, { name: 'v', type: 'UInt64' }], + data: [{ k: 'a', v: 1 }, { k: 'b', v: 2 }], + rows: 2, + statistics: { elapsed: 0.012, bytes_read: 2048 }, + }); + expect(out.columns.map((c) => c.name)).toEqual(['k', 'v']); + expect(out.rows).toEqual([['a', 1], ['b', 2]]); + expect(out.meta).toEqual({ rows: 2, ms: 12, bytes: 2048 }); + }); + it('is defensive about a bare response (no meta/data/statistics/rows)', () => { + const out = parseJsonResult({}); + expect(out.columns).toEqual([]); + expect(out.rows).toEqual([]); + expect(out.meta).toEqual({ rows: 0, ms: null, bytes: null }); + }); +}); + +describe('classifyTile', () => { + const cols = [{ name: 'k', type: 'String' }, { name: 'v', type: 'UInt64' }]; + it('skips an empty result', () => { + expect(classifyTile(cols, [], undefined)).toEqual({ kind: 'skip', reason: 'empty' }); + }); + it('skips a single-row result (a KPI — rendered in D2)', () => { + expect(classifyTile(cols, [['a', 1]], undefined)).toEqual({ kind: 'skip', reason: 'kpi' }); + }); + it('skips a multi-row result with nothing chartable', () => { + const strCols = [{ name: 'a', type: 'String' }, { name: 'b', type: 'String' }]; + expect(classifyTile(strCols, [['x', 'y'], ['z', 'w']], undefined)).toEqual({ kind: 'skip', reason: 'nonChartable' }); + }); + it('charts a multi-row result via autoChart when there is no saved config', () => { + const out = classifyTile(cols, [['a', 1], ['b', 2]], undefined); + expect(out.kind).toBe('chart'); + expect(out.cfg).toMatchObject({ type: 'hbar', x: 0, y: [1] }); + }); + it('honours a valid saved chart config (cloned, not aliased)', () => { + const saved = { cfg: { type: 'line', x: 0, y: [1], series: null } }; + const out = classifyTile(cols, [['a', 1], ['b', 2]], saved); + expect(out.kind).toBe('chart'); + expect(out.cfg).toEqual({ type: 'line', x: 0, y: [1], series: null }); + expect(out.cfg).not.toBe(saved.cfg); + }); + it('falls back to autoChart when the saved config does not fit the columns', () => { + const saved = { cfg: { type: 'bar', x: 99, y: [1], series: null } }; + const out = classifyTile(cols, [['a', 1], ['b', 2]], saved); + expect(out.kind).toBe('chart'); + expect(out.cfg.x).toBe(0); // re-derived a safe default + }); +}); + +// ── core/auth-handoff.js ───────────────────────────────────────────────────── +function memSession(initial = {}) { + const m = new Map(Object.entries(initial)); + return { getItem: (k) => (m.has(k) ? m.get(k) : null), setItem: (k, v) => m.set(k, String(v)), removeItem: (k) => m.delete(k), _map: m }; +} + +describe('auth-handoff snapshot/restore', () => { + it('snapshots only the present auth keys', () => { + const ss = memSession({ oauth_id_token: 't', oauth_idp: 'g', unrelated: 'x' }); + expect(snapshotAuth(ss)).toEqual({ oauth_id_token: 't', oauth_idp: 'g' }); + }); + it('restores present keys and ignores absent ones (and a null snapshot)', () => { + const ss = memSession(); + restoreAuth(ss, { oauth_id_token: 't', ch_basic_auth: 'b' }); + expect(ss.getItem('oauth_id_token')).toBe('t'); + expect(ss.getItem('ch_basic_auth')).toBe('b'); + expect(ss.getItem('oauth_idp')).toBeNull(); + expect(() => restoreAuth(ss, null)).not.toThrow(); + }); + it('AUTH_SS_KEYS covers both OAuth and basic sessions', () => { + expect(AUTH_SS_KEYS).toContain('oauth_id_token'); + expect(AUTH_SS_KEYS).toContain('ch_basic_auth'); + }); + it('hasAuth is true only with a token or basic creds', () => { + expect(hasAuth({ oauth_id_token: 't' })).toBe(true); + expect(hasAuth({ ch_basic_auth: 'b' })).toBe(true); + expect(hasAuth({})).toBe(false); + expect(hasAuth(null)).toBe(false); + }); +}); + +describe('auth-handoff message predicates', () => { + const src = {}; + const ok = (type) => ({ origin: 'https://o', source: src, data: { type } }); + it('isAuthRequest accepts a matching request only', () => { + expect(isAuthRequest(ok(AUTH_REQUEST), 'https://o', src)).toBe(true); + expect(isAuthRequest(null, 'https://o', src)).toBe(false); + expect(isAuthRequest({ ...ok(AUTH_REQUEST), origin: 'https://evil' }, 'https://o', src)).toBe(false); + expect(isAuthRequest({ ...ok(AUTH_REQUEST), source: {} }, 'https://o', src)).toBe(false); + expect(isAuthRequest({ origin: 'https://o', source: src }, 'https://o', src)).toBe(false); // no data + expect(isAuthRequest(ok('other'), 'https://o', src)).toBe(false); + }); + it('isAuthGrant accepts a matching grant only', () => { + expect(isAuthGrant(ok(AUTH_GRANT), 'https://o', src)).toBe(true); + expect(isAuthGrant(null, 'https://o', src)).toBe(false); + expect(isAuthGrant({ ...ok(AUTH_GRANT), origin: 'https://evil' }, 'https://o', src)).toBe(false); + expect(isAuthGrant({ ...ok(AUTH_GRANT), source: {} }, 'https://o', src)).toBe(false); + expect(isAuthGrant({ origin: 'https://o', source: src }, 'https://o', src)).toBe(false); + expect(isAuthGrant(ok('other'), 'https://o', src)).toBe(false); + }); +}); + +// ── ui/dashboard.js ────────────────────────────────────────────────────────── +const chartResult = (meta = { rows: 2, ms: 5, bytes: 100 }) => ({ + columns: [{ name: 'k', type: 'String' }, { name: 'v', type: 'UInt64' }], + rows: [['a', 1], ['b', 2]], meta, +}); +const kpiResult = () => ({ columns: [{ name: 'value', type: 'UInt64' }], rows: [[42]], meta: { rows: 1, ms: 1, bytes: 10 } }); + +function dashApp(favorites, runTile) { + const app = makeApp({ runTile }); + app.state.savedQueries = favorites; + return app; +} + +describe('renderDashboard', () => { + it('renders a header + a chart tile per chartable favorite', async () => { + const favorites = [ + { id: '1', name: 'Chart A', sql: 'chartA', favorite: true }, + { id: '2', name: 'Chart B', sql: 'chartB', favorite: true }, + ]; + const app = dashApp(favorites, vi.fn(async () => chartResult())); + await renderDashboard(app); + expect(app.root.querySelector('.dash-header')).not.toBeNull(); + expect(app.root.querySelector('.dash-back')).not.toBeNull(); + expect(app.root.querySelector('.dash-fav').textContent).toContain('2 favorites'); + expect(app.root.querySelectorAll('.dash-tile').length).toBe(2); + expect(app.root.querySelector('.dash-tile canvas')).not.toBeNull(); + expect(app.root.querySelector('.dash-tile-foot').textContent).toContain('rows'); + }); + + it('uses the singular chip label with exactly one favorite', async () => { + const app = dashApp([{ id: '1', name: 'Q', sql: 'q', favorite: true }], vi.fn(async () => chartResult())); + await renderDashboard(app); + expect(app.root.querySelector('.dash-fav').textContent).toContain('1 favorite'); + }); + + it('skips single-row (KPI) favorites and notes how many are not shown', async () => { + const favorites = [ + { id: '1', name: 'Chart', sql: 'chart', favorite: true }, + { id: '2', name: 'Kpi', sql: 'kpi', favorite: true }, + ]; + 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 + const note = app.root.querySelector('.dash-skip'); + expect(note.style.display).toBe(''); + expect(note.textContent).toBe('1 not shown'); + }); + + it('shows a per-tile error when the query fails', async () => { + const app = dashApp([{ id: '1', name: 'Bad', sql: 'boom', favorite: true }], vi.fn(async () => ({ error: 'Cannot execute' }))); + await renderDashboard(app); + expect(app.root.querySelector('.dash-tile-error').textContent).toBe('Cannot execute'); + expect(app.root.querySelector('.dash-skip').style.display).toBe('none'); // an error is not a skip + }); + + it('omits ms/bytes from the footer when CH did not report them', async () => { + const app = dashApp([{ id: '1', name: 'Q', sql: 'q', favorite: true }], + vi.fn(async () => chartResult({ rows: 2, ms: null, bytes: null }))); + await renderDashboard(app); + expect(app.root.querySelector('.dash-tile-foot').children.length).toBe(1); + }); + + it('has a theme toggle wired to app.toggleTheme', async () => { + const toggleTheme = vi.fn(); + const app = makeApp({ runTile: vi.fn(async () => chartResult()), toggleTheme }); + app.state.theme = 'dark'; // exercise the dark-theme icon branch + app.state.savedQueries = [{ id: '1', name: 'Q', sql: 'q', favorite: true }]; + await renderDashboard(app); + const btn = app.root.querySelector('.dash-icobtn'); + expect(btn).toBeTruthy(); + btn.dispatchEvent(new Event('click', { bubbles: true })); + expect(toggleTheme).toHaveBeenCalled(); + }); + + it('redirects to login once (no tiles) when the session cannot be refreshed', async () => { + const onSignedOut = vi.fn(); + const app = makeApp({ + runTile: vi.fn(async () => chartResult()), + ensureFreshToken: vi.fn(async () => false), + chCtx: { onSignedOut }, + }); + app.state.savedQueries = [ + { id: '1', name: 'Q', sql: 'q', favorite: true }, + { id: '2', name: 'R', sql: 'r', favorite: true }, + ]; + await renderDashboard(app); + expect(onSignedOut).toHaveBeenCalledTimes(1); // one redirect, not one per tile + expect(app.runTile).not.toHaveBeenCalled(); + expect(app.root.querySelectorAll('.dash-tile').length).toBe(0); + }); + + it('tears down the previous tiles Chart.js instances on Refresh (no leak)', async () => { + const charts = []; + const app = dashApp([{ id: '1', name: 'Q', sql: 'q', favorite: true }], vi.fn(async () => chartResult())); + const Base = app.Chart; + app.Chart = class extends Base { constructor(...a) { super(...a); charts.push(this); } }; + await renderDashboard(app); + expect(charts).toHaveLength(1); + await app.root.querySelector('.dash-btn').onclick(); + expect(charts).toHaveLength(2); + expect(charts[0].destroyed).toBe(true); // prior instance destroyed, not orphaned + }); + + it('shows an empty state when there are no favorites', async () => { + const app = dashApp([], vi.fn()); + await renderDashboard(app); + expect(app.root.querySelector('.dash-empty').style.display).toBe(''); + expect(app.root.querySelectorAll('.dash-tile').length).toBe(0); + }); + + it('Refresh re-runs every tile', async () => { + const runTile = vi.fn(async () => chartResult()); + const app = dashApp([{ id: '1', name: 'Q', sql: 'q', favorite: true }], runTile); + await renderDashboard(app); + expect(runTile).toHaveBeenCalledTimes(1); + await app.root.querySelector('.dash-btn').onclick(); + expect(runTile).toHaveBeenCalledTimes(2); + }); + + it('renders read-only tiles with no interactive chart-config bar (D1)', async () => { + const app = dashApp([{ id: '1', name: 'Q', sql: 'q', favorite: true }], vi.fn(async () => chartResult())); + await renderDashboard(app); + expect(app.root.querySelector('.dash-tile canvas')).not.toBeNull(); + expect(app.root.querySelector('.dash-tile .chart-config')).toBeNull(); // controls omitted, not hidden + expect(app.root.querySelector('.dash-tile .chart-select')).toBeNull(); + }); +}); + +// ── app.js: runTile + auth handoff wiring ──────────────────────────────────── +function jwt(payload) { + const b = (o) => Buffer.from(JSON.stringify(o)).toString('base64url'); + return `${b({ alg: 'RS256' })}.${b(payload)}.sig`; +} +const validToken = jwt({ email: 'me@example.com', exp: Math.floor(Date.now() / 1000) + 3600 }); + +function resp(opts) { + return { + ok: opts.ok ?? true, status: opts.status ?? 200, + json: async () => opts.json, text: async () => opts.text ?? JSON.stringify(opts.json), + clone() { return this; }, + headers: { get: () => null }, + }; +} +function makeFetch(routes) { + return vi.fn(async (url, init) => { + const sql = init && init.body; + for (const [test, r] of routes) if (test(url, sql)) return typeof r === 'function' ? r() : r; + return resp({ json: { data: [] } }); + }); +} +function appEnv(over = {}) { + const root = document.createElement('div'); + document.body.appendChild(root); + return { + root, document, window, + location: { host: 'ch.example', origin: 'https://ch.example', pathname: '/sql', search: '', hash: '', href: 'https://ch.example/sql' }, + sessionStorage: memSession({ oauth_id_token: validToken }), + crypto: webcrypto, Editor: createCodeMirrorEditor, Chart: FakeChart, + fetch: makeFetch([]), now: () => 0, retryMs: 0, handoffMs: 10, handoffListenMs: 10, + navigator: { clipboard: { writeText: vi.fn(async () => {}) } }, + ...over, + }; +} +const msg = (data, source, origin = 'https://ch.example') => { + const e = new Event('message'); + e.data = data; e.origin = origin; e.source = source; + return e; +}; + +describe('app.runTile', () => { + it('returns the parsed result on success', async () => { + const app = createApp(appEnv({ + fetch: makeFetch([[(u, sql) => /SELECT k/.test(sql || ''), + resp({ json: { meta: [{ name: 'k', type: 'String' }, { name: 'v', type: 'UInt64' }], data: [{ k: 'a', v: 1 }], statistics: { elapsed: 0.01, bytes_read: 2048 } } })]]), + })); + const r = await app.runTile('SELECT k, v FROM t'); + expect(r.columns.map((c) => c.name)).toEqual(['k', 'v']); + expect(r.rows).toEqual([['a', 1]]); + expect(r.meta).toMatchObject({ ms: 10, bytes: 2048 }); + }); + it('reports the CH error message on a rejected query', async () => { + const app = createApp(appEnv({ + fetch: makeFetch([[(u, sql) => /SELECT/.test(sql || ''), resp({ ok: false, status: 500, text: 'Cannot execute query in readonly mode' })]]), + })); + expect((await app.runTile('SELECT 1')).error).toMatch(/readonly/); + }); + 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' }); + }); +}); + +describe('app config base on the dashboard route', () => { + it('resolves config.json from /sql, not /sql/dashboard', async () => { + const fetch = makeFetch([]); + const app = createApp(appEnv({ + fetch, + location: { host: 'ch.example', origin: 'https://ch.example', pathname: '/sql/dashboard', search: '', hash: '', href: 'https://ch.example/sql/dashboard' }, + })); + await app.ensureConfig(); + const urls = fetch.mock.calls.map((c) => c[0]); + expect(urls.some((u) => /\/sql\/config\.json$/.test(u))).toBe(true); + expect(urls.some((u) => /dashboard\/config\.json/.test(u))).toBe(false); + }); +}); + +describe('app.renderDashboard', () => { + it('renders the favorites dashboard into the root', async () => { + const app = createApp(appEnv({ + fetch: makeFetch([[(u, sql) => /mychart/.test(sql || ''), + resp({ json: { meta: [{ name: 'k', type: 'String' }, { name: 'v', type: 'UInt64' }], data: [{ k: 'a', v: 1 }, { k: 'b', v: 2 }] } })]]), + })); + app.state.savedQueries = [{ id: '1', name: 'Q', sql: 'SELECT k, v FROM mychart', favorite: true }]; + await app.renderDashboard(); + expect(app.root.querySelector('.dash-tile canvas')).not.toBeNull(); + }); +}); + +describe('app auth handoff', () => { + it('openDashboard opens a tab and grants credentials when the child asks', () => { + const child = { postMessage: vi.fn() }; + const app = createApp(appEnv({ openWindow: vi.fn(() => child) })); + app.openDashboard(); + window.dispatchEvent(msg({ type: 'nope' }, child)); // ignored (wrong type) + window.dispatchEvent(msg({ type: AUTH_REQUEST }, child)); + expect(child.postMessage).toHaveBeenCalledTimes(1); + const [payload, origin] = child.postMessage.mock.calls[0]; + expect(payload.type).toBe(AUTH_GRANT); + expect(payload.creds.oauth_id_token).toBe(validToken); + expect(origin).toBe('https://ch.example'); + }); + it('openDashboard tolerates a blocked popup (null window)', () => { + const app = createApp(appEnv({ openWindow: () => null })); + expect(() => app.openDashboard()).not.toThrow(); + }); + it('openDashboard does not grant when the opener holds no credentials', () => { + const child = { postMessage: vi.fn() }; + const app = createApp(appEnv({ sessionStorage: memSession({}), openWindow: () => child })); + app.openDashboard(); + window.dispatchEvent(msg({ type: AUTH_REQUEST }, child)); + expect(child.postMessage).not.toHaveBeenCalled(); + }); + it('receiveAuthHandoff resolves false with no opener', async () => { + const app = createApp(appEnv()); + await expect(app.receiveAuthHandoff({})).resolves.toBe(false); + }); + it('applies an OAuth grant and re-seeds in-memory auth fields', async () => { + const ss = memSession({}); + const app = createApp(appEnv({ sessionStorage: ss })); + const opener = { postMessage: vi.fn() }; + const newTok = jwt({ email: 'x@y.com', exp: Math.floor(Date.now() / 1000) + 3600 }); + const p = app.receiveAuthHandoff({ opener }); + expect(opener.postMessage).toHaveBeenCalledWith({ type: AUTH_REQUEST }, 'https://ch.example'); + window.dispatchEvent(msg({ type: 'other' }, opener)); // ignored + window.dispatchEvent(msg({ type: AUTH_GRANT, creds: { oauth_id_token: newTok, oauth_refresh_token: 'r', oauth_idp: 'g', oauth_origin: 'https://cluster' } }, opener)); + await expect(p).resolves.toBe(true); + expect(app.token).toBe(newTok); + expect(app.idpId).toBe('g'); + expect(app.chCtx.origin).toBe('https://cluster'); + expect(ss.getItem('oauth_id_token')).toBe(newTok); + }); + it('applies a basic-auth grant', async () => { + const ss = memSession({}); + const app = createApp(appEnv({ sessionStorage: ss })); + const opener = { postMessage: vi.fn() }; + const p = app.receiveAuthHandoff({ opener }); + window.dispatchEvent(msg({ type: AUTH_GRANT, creds: { ch_basic_auth: 'YmFzZQ==', ch_basic_user: 'u', ch_basic_origin: 'https://c2' } }, opener)); + await expect(p).resolves.toBe(true); + expect(app.authMode).toBe('basic'); + expect(app.chCtx.origin).toBe('https://c2'); + expect(ss.getItem('ch_basic_auth')).toBe('YmFzZQ=='); + }); + it('ignores an empty grant and applies a later valid one', async () => { + const ss = memSession({}); + const app = createApp(appEnv({ sessionStorage: ss })); + const opener = { postMessage: vi.fn() }; + const p = app.receiveAuthHandoff({ opener }); + window.dispatchEvent(msg({ type: AUTH_GRANT, creds: {} }, opener)); // empty — ignored, keeps waiting + const newTok = jwt({ email: 'z@z.com', exp: Math.floor(Date.now() / 1000) + 3600 }); + window.dispatchEvent(msg({ type: AUTH_GRANT, creds: { oauth_id_token: newTok } }, opener)); + await expect(p).resolves.toBe(true); + expect(app.token).toBe(newTok); + }); + it('resolves false when the request times out', async () => { + const app = createApp(appEnv({ handoffMs: 5 })); + await expect(app.receiveAuthHandoff({ opener: { postMessage: vi.fn() } })).resolves.toBe(false); + }); +}); diff --git a/tests/unit/file-menu.test.js b/tests/unit/file-menu.test.js index b6f4d13..b0ec629 100644 --- a/tests/unit/file-menu.test.js +++ b/tests/unit/file-menu.test.js @@ -23,6 +23,31 @@ const picker = (i) => document.querySelectorAll('.file-menu input[type=file]')[i afterEach(() => document.body.replaceChildren()); +describe('open as dashboard', () => { + it('opens the dashboard when at least one query is favorited', () => { + const app = mount(); + app.actions.openDashboard = vi.fn(); + app.state.savedQueries = [{ id: '1', name: 'Q', sql: 'SELECT 1', favorite: true }]; + openFileMenu(app); + const btn = item(/Open as dashboard/); + expect(btn).toBeTruthy(); + click(btn); + expect(app.actions.openDashboard).toHaveBeenCalled(); + }); + + it('is disabled (with a reason) and toasts when there are no favorites', () => { + const app = mount(); + app.actions.openDashboard = vi.fn(); + app.state.savedQueries = [{ id: '1', name: 'Q', sql: 'SELECT 1', favorite: false }]; + openFileMenu(app); + const btn = item(/Open as dashboard/); + expect(btn.textContent).toContain('no favorites'); + click(btn); + expect(app.actions.openDashboard).not.toHaveBeenCalled(); + expect(toast()).toContain('Star a query'); + }); +}); + describe('library title', () => { it('renders the name + dirty dot; inline rename commits on Enter and persists', () => { const app = mount(); @@ -85,9 +110,9 @@ describe('file menu', () => { ]; openFileMenu(app); expect([...document.querySelectorAll('.fm-label')].map((l) => l.textContent)).toEqual( - ['New Library', 'Save JSON', 'Open…', 'Append…', 'Download Markdown', 'Download SQL']); + ['New Library', 'Open as dashboard', 'Save JSON', 'Open…', 'Append…', 'Download Markdown', 'Download SQL']); expect([...document.querySelectorAll('.fm-section')].map((s) => s.textContent)).toEqual( - ['Save library', 'Load from file', 'Share / publish']); + ['Dashboard', 'Save library', 'Load from file', 'Share / publish']); expect(document.querySelector('.fm-count').textContent).toBe('2 queries in Library'); openFileMenu(app); expect(document.querySelectorAll('.file-menu')).toHaveLength(1); @@ -175,7 +200,7 @@ describe('Open / Append (JSON only)', () => { openFileMenu(app); const replaceInput = picker(0); replaceInput.click = vi.fn(); - click(item(/Open/)); + click(item(/Open…/)); expect(document.querySelector('.file-menu')).toBeNull(); // menu closed expect(replaceInput.click).toHaveBeenCalled(); // user picks a file → confirm dialog (current library non-empty, plural copy) diff --git a/tests/unit/format.test.js b/tests/unit/format.test.js index d31cc7d..cfd41da 100644 --- a/tests/unit/format.test.js +++ b/tests/unit/format.test.js @@ -191,6 +191,12 @@ describe('prepareExportSql', () => { expect(prepareExportSql(' ')).toEqual({ sql: '', format: 'TabSeparatedWithNames' }); expect(prepareExportSql(null)).toEqual({ sql: '', format: 'TabSeparatedWithNames' }); }); + it('peels trailing comments before resolving FORMAT (line + block)', () => { + // A trailing comment must not hide an existing FORMAT (else it doubles), nor + // swallow an appended one. + expect(prepareExportSql('SELECT 1 FORMAT CSV -- note')).toEqual({ sql: 'SELECT 1 FORMAT CSV', format: 'CSV' }); + expect(prepareExportSql('SELECT 1 /* note */')).toEqual({ sql: 'SELECT 1\nFORMAT TabSeparatedWithNames', format: 'TabSeparatedWithNames' }); + }); }); describe('isSchemaMutatingSql', () => { diff --git a/tests/unit/main.test.js b/tests/unit/main.test.js index 7223753..dfe06b0 100644 --- a/tests/unit/main.test.js +++ b/tests/unit/main.test.js @@ -16,6 +16,9 @@ function fakeApp(over = {}) { ensureConfig: vi.fn(async () => ({})), setTokens: vi.fn(function (id) { this.token = id; }), renderApp: vi.fn(), + renderDashboard: vi.fn(), + receiveAuthHandoff: vi.fn(async () => false), + ensureFreshToken: vi.fn(async () => false), showLogin: vi.fn(), // Default mirrors the real controller: signed in iff a token is held. // Tests that exercise a basic session override this directly. @@ -180,6 +183,55 @@ describe('bootstrap', () => { expect(app.state.tabs.value[0].name).toBe('Untitled'); }); + const dashLoc = (over = {}) => ({ href: 'https://ch/sql/dashboard', origin: 'https://ch', pathname: '/sql/dashboard', search: '', hash: '', ...over }); + + it('renders the dashboard when signed in on the /sql/dashboard route', async () => { + const app = fakeApp({ token: valid, isSignedIn: () => true }); + await bootstrap(app, fakeEnv({ location: dashLoc() })); + expect(app.renderDashboard).toHaveBeenCalled(); + expect(app.renderApp).not.toHaveBeenCalled(); + }); + + it('attempts the auth handoff, then renders the dashboard once it signs the tab in', async () => { + const app = fakeApp(); + app.receiveAuthHandoff = vi.fn(async () => { app.token = valid; return true; }); + const env = fakeEnv({ location: dashLoc(), opener: { postMessage: vi.fn() } }); + await bootstrap(app, env); + expect(app.receiveAuthHandoff).toHaveBeenCalledWith(env); + expect(app.renderDashboard).toHaveBeenCalled(); + expect(app.showLogin).not.toHaveBeenCalled(); + }); + + it('falls back to login on a cold dashboard visit with no handoff', async () => { + const app = fakeApp(); + await bootstrap(app, fakeEnv({ location: dashLoc() })); + expect(app.receiveAuthHandoff).toHaveBeenCalled(); + expect(app.ensureFreshToken).toHaveBeenCalled(); // tried a refresh before giving up + expect(app.showLogin).toHaveBeenCalledWith(null); + expect(app.renderDashboard).not.toHaveBeenCalled(); + }); + + it('refreshes an expired handed-off token before falling back to login', async () => { + // The handoff applies an expired id_token (isSignedIn() still false); a + // refresh via ensureFreshToken recovers a valid one, so we render — not login. + const app = fakeApp({ isSignedIn() { return this.token === valid; } }); + app.receiveAuthHandoff = vi.fn(async () => { app.token = 'expired'; return true; }); + app.ensureFreshToken = vi.fn(async () => { app.token = valid; return true; }); + await bootstrap(app, fakeEnv({ location: dashLoc(), opener: { postMessage: vi.fn() } })); + expect(app.ensureFreshToken).toHaveBeenCalled(); + expect(app.renderDashboard).toHaveBeenCalled(); + expect(app.showLogin).not.toHaveBeenCalled(); + }); + + it('skips editor share-link seeding on the dashboard route', async () => { + const app = fakeApp({ token: valid, isSignedIn: () => true }); + const sql = 'SELECT 1'; + const hash = '#' + btoa(unescape(encodeURIComponent(sql))); + await bootstrap(app, fakeEnv({ location: dashLoc({ href: 'https://ch/sql/dashboard' + hash, hash }) })); + expect(app.state.tabs.value[0].sql).toBe(''); // not seeded — dashboard has no editor tab + expect(app.renderDashboard).toHaveBeenCalled(); + }); + it('preserves extra query params while stripping oauth ones', async () => { const app = fakeApp({ token: valid, isSignedIn: () => true }); const env = fakeEnv({