From ff5b58fdee4171ce094ba0c5e2605d1310d8482c Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Thu, 23 Jul 2026 15:01:47 +0000 Subject: [PATCH 1/4] feat: unify workspace and dashboard routing (#407) Route Workbench and Dashboard through one canonical /sql entry point, keep workspace state live across surface changes, and retire detached dashboard handoff storage. Preserve state-bound OAuth return routes, harden asynchronous workspace navigation, and update deployment, architecture, and regression coverage. Co-Authored-By: OpenAI Codex Claude-Session: unavailable (OpenAI Codex) --- CHANGELOG.md | 9 + README.md | 18 +- build/check-boundaries.mjs | 2 +- build/local.py | 6 +- deploy/http_handlers.xml | 7 +- deploy/nginx/default.conf.template | 8 +- docs/ADR-0003-dashboard-viewing.md | 236 ++++----- docs/ARCHITECTURE.md | 2 +- src/application/connection-session.ts | 138 +---- src/core/auth-handoff.ts | 70 --- src/core/dashboard.ts | 23 +- src/core/handoff-token.ts | 26 - src/core/sql-route.ts | 55 ++ .../application/dashboard-open-source.ts | 57 -- src/dashboard/application/session-bundle.ts | 125 ----- src/env.types.ts | 3 - src/main.ts | 63 ++- src/styles.css | 18 +- src/ui/app.ts | 342 ++++++------ src/ui/app.types.ts | 73 +-- src/ui/dashboard.ts | 231 ++++---- src/ui/file-menu.ts | 41 +- src/ui/menu.ts | 15 + src/ui/shortcuts.ts | 5 + src/ui/workbench/workbench-shell.ts | 37 +- src/workspace/detached-views-store.types.ts | 27 - src/workspace/handoff-store.types.ts | 33 -- .../indexeddb-detached-views-store.ts | 144 ----- src/workspace/indexeddb-handoff-store.ts | 117 ---- tests/helpers/auth-fixtures.ts | 2 +- tests/helpers/fake-app.ts | 40 +- tests/unit/app.test.ts | 498 ++++++++++++------ tests/unit/connection-session.test.ts | 185 +------ tests/unit/cross-tab-consistency.test.ts | 11 +- tests/unit/dashboard-boundaries.test.js | 7 +- tests/unit/dashboard-open-source.test.ts | 101 ---- tests/unit/dashboard.test.ts | 473 +++++------------ tests/unit/file-menu.test.ts | 42 +- tests/unit/handoff-token.test.ts | 56 -- .../indexeddb-detached-views-store.test.ts | 257 --------- tests/unit/indexeddb-handoff-store.test.ts | 212 -------- tests/unit/main.test.ts | 129 +++-- tests/unit/menu.test.ts | 17 +- tests/unit/session-bundle.test.ts | 174 ------ tests/unit/shortcuts.test.ts | 10 + tests/unit/sql-route.test.ts | 68 +++ 46 files changed, 1329 insertions(+), 2884 deletions(-) delete mode 100644 src/core/auth-handoff.ts delete mode 100644 src/core/handoff-token.ts create mode 100644 src/core/sql-route.ts delete mode 100644 src/dashboard/application/dashboard-open-source.ts delete mode 100644 src/dashboard/application/session-bundle.ts delete mode 100644 src/workspace/detached-views-store.types.ts delete mode 100644 src/workspace/handoff-store.types.ts delete mode 100644 src/workspace/indexeddb-detached-views-store.ts delete mode 100644 src/workspace/indexeddb-handoff-store.ts delete mode 100644 tests/unit/dashboard-open-source.test.ts delete mode 100644 tests/unit/handoff-token.test.ts delete mode 100644 tests/unit/indexeddb-detached-views-store.test.ts delete mode 100644 tests/unit/indexeddb-handoff-store.test.ts delete mode 100644 tests/unit/session-bundle.test.ts create mode 100644 tests/unit/sql-route.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 070d0bd3..f00a15ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,15 @@ auto-generated per-PR notes; this file is the curated, human-readable history. ## [Unreleased] ### Added +- **Unified `/sql` routing for Workbench and Dashboard surfaces** (#407). + Workspace identity, surface, and presentation mode now live in canonical + `ws`, `surface`, and `mode` query parameters. Workbench/Dashboard switches + stay in the same tab with useful Back-button history, while View/Edit uses + replacement history. Dashboard view mode renders the same live workspace + document without authoring controls; an explicit missing workspace never + falls back, and an empty workspace offers Create only in edit mode. The old + `/sql/dashboard` bootstrap split, detached Dashboard snapshot stores, + one-time state handoff, and cross-tab credential handoff have been removed. - **Multi-workspace local persistence foundation** (#406). Stored workspaces now use the V2 contract with separate immutable opaque `id`, immutable human-readable URL `key`, and mutable display `name`. IndexedDB stores one diff --git a/README.md b/README.md index 7a449a9c..a2799f97 100644 --- a/README.md +++ b/README.md @@ -532,10 +532,11 @@ npm run dev # build + serve dist/ at http://localhost:8900 ### Run in Docker The production image is a static **nginx** server for the single-file SPA — no -application backend. It serves `/sql` (and the `/sql/dashboard` client route), -serves a `config.json` you provide at `/sql/config.json`, and answers `/healthz` -for probes. Queries are **not** proxied: the browser POSTs them straight to the -chosen ClickHouse cluster, exactly like the on-cluster deployment. +application backend. It serves `/sql`, including Workbench and Dashboard +surfaces selected by query parameters, serves a `config.json` you provide at +`/sql/config.json`, and answers `/healthz` for probes. Queries are **not** +proxied: the browser POSTs them straight to the chosen ClickHouse cluster, +exactly like the on-cluster deployment. Pull the published multi-arch image (`linux/amd64` + `linux/arm64`): @@ -697,12 +698,9 @@ 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. - 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. + The SPA handler serves `/sql`; Workbench and Dashboard bookmarks differ only + by the `ws`, `surface`, and optional `mode` query parameters. +2. Register the single redirect URI `https:///sql` with your OAuth IdP. 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/check-boundaries.mjs b/build/check-boundaries.mjs index ce88cd72..90589832 100644 --- a/build/check-boundaries.mjs +++ b/build/check-boundaries.mjs @@ -3,7 +3,7 @@ // service layer coordinates state and network, it does not reach into DOM // rendering or the CodeMirror editor adapters. // Phase 3 — the route sessions must not import each other's implementation -// modules (ui/workbench ↔ ui/dashboard), and the dashboard route must not +// modules (ui/workbench ↔ ui/dashboard), and the Dashboard surface must not // depend on the editor ports at all. // Phase 5 — neither route shell (ui/workbench/**, ui/dashboard.ts + // ui/dashboard/**) may import src/ui/app.ts: both receive everything they diff --git a/build/local.py b/build/local.py index b5e46623..e18fac95 100644 --- a/build/local.py +++ b/build/local.py @@ -242,10 +242,8 @@ def do_GET(self): if path.endswith("/config.json"): self._send(CONFIG, "application/json; charset=utf-8") return - # 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"): + # Workbench and Dashboard are query-selected surfaces of /sql. + if path.rstrip("/") in ("", "/sql", "/sql.html"): try: with open(SPA, "rb") as f: html = f.read() diff --git a/deploy/http_handlers.xml b/deploy/http_handlers.xml index 046cab23..8f1fa568 100644 --- a/deploy/http_handlers.xml +++ b/deploy/http_handlers.xml @@ -6,7 +6,6 @@ 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 @@ -24,10 +23,8 @@ - - regex:^/sql(/dashboard)?/?$ + + regex:^/sql/?$ GET static diff --git a/deploy/nginx/default.conf.template b/deploy/nginx/default.conf.template index d8cda29d..2ccd1ad1 100644 --- a/deploy/nginx/default.conf.template +++ b/deploy/nginx/default.conf.template @@ -31,12 +31,12 @@ server { return 302 /sql; } - # The workbench (/sql) and the client-side dashboard route (/sql/dashboard) - # are the SAME single-file SPA. alias to a file (no captured path appended - # inside a regex location). The CSP's connect-src bounds where the + # Workbench and Dashboard are query-selected surfaces of /sql. Alias to a + # file (no captured path appended inside a regex location). CSP connect-src + # bounds where the # sessionStorage tokens can be sent; script-src/style-src need # 'unsafe-inline' because the JS+CSS are inlined into this one HTML file. - location ~ ^/sql(/dashboard)?/?$ { + location ~ ^/sql/?$ { alias /app/sql.html; # NB: set the charset via the `charset` directive, NOT inside # default_type — a "text/html; charset=…" content_type string defeats diff --git a/docs/ADR-0003-dashboard-viewing.md b/docs/ADR-0003-dashboard-viewing.md index 205e7d17..3b5870b9 100644 --- a/docs/ADR-0003-dashboard-viewing.md +++ b/docs/ADR-0003-dashboard-viewing.md @@ -1,160 +1,102 @@ -# ADR-0003: Dashboard viewing — edit vs view modes, and the one-time cross-tab state handoff +# ADR-0003: Dashboard viewing and unified `/sql` routes -- **Status:** Accepted — 2026-07-18 (#288 + #302, Dashboard v1 phase 6, the final - phase of epic #280). Implemented on branch `feat/dashboard-viewing-288`. -- **Date:** 2026-07-18 -- **Context tracking:** roadmap #68 (Dashboard track); epic #280; closes #153. -- **Related:** #149 (original standalone-dashboard route + postMessage credential - handoff — its *state*-transport decision is superseded here), #284 (IndexedDB - `StoredWorkspaceV1` persistence, whose adapter pattern this reuses), #287 - (portable bundle codecs + transactional import planner this builds on). +- **Status:** Accepted; detached-snapshot decision superseded by #407 on + 2026-07-23 +- **Date:** 2026-07-18; revised 2026-07-23 +- **Context tracking:** roadmap #68; #288, #302, #406, #407 ## Context -Phases 1–5 of #280 made the Dashboard a first-class module: an explicit -`StoredWorkspaceV1` aggregate persisted atomically to IndexedDB (#284), an -independent read-only `DashboardViewerSession` + `flow@1` layout (#286), and a -canonical `PortableBundleV1` with a transactional import planner (#287). Phase 6 -(#288) is the viewing surface: how a Dashboard is *opened* — bookmarkably for the -current workspace, and in a detached read-only tab — without the read-only path -dragging in Workbench or editor construction. In parallel, #302 asked to move -Dashboard navigation and Dashboard-scoped file operations out of the (overloaded) -Workbench File menu and next to the Dashboard resource itself. - -The original #288 spec framed the second open kind as a *temporary, one-time, -non-bookmarkable* "session-bundle" for full-screen preview and external-bundle -viewing. During planning the owner refined this into a cleaner, more durable -product model, which this ADR records. +The original Dashboard viewing design separated an editable primary workspace +from durable read-only snapshots. Opening a view created a second workspace +record through a one-time IndexedDB handoff and opened a separate +`/sql/dashboard` application tab. That model duplicated local data, required +parallel stores and credential/state transport, and made view mode diverge from +the workspace users were actually editing. -## Decision - -### Two explicit viewing modes +Multi-workspace persistence (#406) established an immutable human-readable +workspace `key` as the canonical URL identity. Unified routing (#407) uses that +identity for both application surfaces and treats View/Edit as presentation +modes over one live workspace. -| | **Edit mode** | **View mode** | -|---|---|---| -| Entry | Workbench header `Dashboard →` control | Dashboard header **File → "Open for viewing…"** | -| Tab | new tab | new tab | -| Open source | `current-workspace` (`?ws=&dash=`) | detached snapshot of the current dashboard | -| Storage | **shared** primary workspace collection (`asb-workspaces-v2`) | its **own** detached store (`asb-dashboard-views`), fresh id | -| Editable | yes — drag reorder + layout preset persist to the shared aggregate | **read-only** | -| Auth | existing postMessage credential handoff (#149) | same | -| Survives relogin / reload | yes (shared store) | yes (own persisted record) | - -`Dashboard →` opens a **new tab** (an owner override of #302's "current tab" -wording), keeping the Workbench tab available and reusing the existing new-tab -credential handoff. +## Decision -### The `DashboardOpenSource` contract +Workbench and Dashboard are surfaces of the same `/sql` application: -```ts -type DashboardOpenSource = - | { kind: 'current-workspace'; workspaceKey: string; dashboardId: string } - | { kind: 'session-bundle'; token: string; dashboardId: string }; +```text +/sql?ws=clickhouse_operations +/sql?ws=clickhouse_operations&surface=dashboard +/sql?ws=clickhouse_operations&surface=dashboard&mode=view ``` -Encoded as **query params on `/dashboard`** — never path segments — so the -pathname stays exactly `/dashboard` and `isDashboardRoute`/`configBase`/the OAuth -`redirect_uri` (all keyed on the `/dashboard` suffix) are untouched, and the -params survive `bootstrap`'s OAuth-param strip automatically. `?ws=&dash=` is -current-workspace; `?st=&dash=` is session-bundle. - -> **Critical constraint:** the session token param is **`st`**, never `state`. -> `bootstrap` (`src/main.ts`) reads `?state` as the OAuth CSRF value; a token -> there would raise "OAuth state mismatch". - -**Mode is discriminated by which store the `ws` key resolves in**, not by a -spoofable URL flag: the key present in the primary workspace store → edit mode; -present in the detached views store → view mode; in neither → a not-found panel -that executes nothing (never silently opens a different dashboard). - -### The one-time cross-tab state handoff - -"Open for viewing…" hands the dashboard *state* (not credentials) to the new tab -through a one-time IndexedDB token, then materializes a durable detached copy: - -1. Snapshot the current dashboard's dependency closure into a `PortableBundleV1` - (`buildDashboardExportBundle`). -2. Generate an unguessable 256-bit token (`crypto.getRandomValues`); write a - record `{ text: bundleJSON, dashboardId, detachedWorkspaceId, expiresAt }` to - a dedicated IndexedDB database (`asb-dashboard-handoff`) **before** opening the - tab. -3. `window.open('/dashboard?st=&dash=')` and grant the credential - handoff. -4. The new tab **atomically consumes + deletes** the record in one readwrite - transaction (`take` = get+delete, then reject if expired), and strips `st` - from the URL via `history.replaceState`. -5. It **materializes** the bundle into the detached store under - `detachedWorkspaceId`, assigns the detached copy a stable local key, rewrites - the URL to `?ws=&dash=`, - and renders read-only. - -Auth is orthogonal and unchanged: the new tab still restores credentials via the -#149 postMessage handoff, falling back to a normal OAuth relogin (which the -detached `?ws=` URL survives). - -## Consequences / deliberate evolutions of #288 - -- **"session-bundle is not bookmarkable" is refined, not violated.** The `?st=` - token URL is genuinely one-time and dead after consumption — not bookmarkable. - The *detached view it produces* (`?ws=`) is persistent and - bookmarkable/relogin-surviving. This is the intended product behavior: a - view-mode tab you can leave open, reload, and re-authenticate into. -- **External-file *viewing* + the untrusted-bundle "Run Dashboard on ``" - trust preflight are descoped.** External `.json` files go through #302's - transactional **Import Dashboard…** (validate + commit via the planner). - "Open for viewing…" detaches the workspace's *own*, already-trusted dashboard, - so there is no untrusted external SQL to gate. The viewer's existing safety - limits (row/byte caps, bounded concurrency, per-tile cancellation, stale-wave - protection, no Setup execution) still apply. -- **A new IndexedDB store family.** Two dedicated databases join the primary - workspace database (now `asb-workspaces-v2`): - `asb-dashboard-handoff` (one-time tokens) and `asb-dashboard-views` - (multi-record detached snapshots, keyed by workspace id, with a small retention - cap so abandoned views don't grow unbounded). Both reuse the #284 adapter - pattern (lazy cached open, single readwrite-txn atomicity) behind injected - seams, so the pure/seam logic stays 100%-covered and tests use in-memory fakes. -- **Read-only path stays clean.** The viewer path constructs no Workbench/editor - modules; the `check:arch` boundary guard + `dashboard-boundaries.test.js` are - extended to the new `dashboard/application` + `workspace` modules. -- **File-menu ownership clarified (#302).** The Workbench File menu owns - workspace/query operations only; Dashboard navigation moves to a Workbench - header `Dashboard →` control, and Dashboard import/export + "Open for viewing…" - move to a resource-scoped File menu on the Dashboard header. +The pure route contract owns `ws`, `surface`, and `mode`: + +- absent or unknown `surface` means Workbench; +- `surface=dashboard` means Dashboard; +- Dashboard edit is the default, so `mode=edit` is accepted but omitted from + canonical URLs; +- `mode=view` renders the same current workspace dashboard without mutation + controls; +- unrelated parameters are preserved until their owning flow consumes them. + +An explicit `ws` resolves exactly that workspace key. Failure renders +**Workspace not found** and never falls back. An implicit `/sql` open resolves +the last-used workspace, deterministically selects an existing workspace when +needed, or provisions the default workspace, then rewrites the URL with its +canonical key. + +Surface changes use same-tab `history.pushState()` so browser Back remains +useful. View/Edit changes use `history.replaceState()` so presentation toggles +do not pollute history. The Dashboard header exposes +`[Workspace | Dashboard] [View | Edit]`. + +Each workspace owns zero or one dashboard: + +- edit mode shows **Create dashboard** when none exists, but visiting does not + create one; +- view mode shows **This workspace has no dashboard** and executes no queries; +- view mode registers no reorder, resize, delete, layout-persistence, or other + authoring paths. + +## Superseded implementation + +The following original ADR decisions are intentionally retired: + +- pathname-based `/sql/dashboard` bootstrapping; +- default new-tab Workbench/Dashboard navigation; +- `DashboardOpenSource` current-workspace/session-bundle discrimination; +- the `st` one-time state transport parameter; +- `asb-dashboard-handoff` and `asb-dashboard-views`; +- detached workspace materialization and retention; +- **Open for viewing…** snapshot creation; +- store-membership-based edit/view discrimination; +- dashboard-specific cross-tab credential handoff. + +There is no migration requirement for those development-era URLs or IndexedDB +records. + +## Consequences + +- Edit and view always observe the same canonical workspace/dashboard data. +- A bookmarked view remains read-only in presentation, not authorization; the + local user can switch back to edit. +- Workbench and Dashboard share authentication, configuration, workspace + refresh, import/export, and query execution without parallel bootstrap + applications. +- Dashboard route resources are disposed when switching surfaces or rebuilding + the current surface; the Workbench shell likewise disposes signal and media + listeners before remounting. +- OAuth uses one `/sql` redirect URI. Callback cleanup retains route parameters + while removing only OAuth callback parameters. ## Alternatives considered -- **Same-tab navigation for `Dashboard →`** (#302 as literally written): rejected - by the owner in favor of a new tab, to keep the Workbench visible. -- **Ephemeral one-time view (strict original #288):** the view would not survive - relogin/close. Rejected — the owner wants a durable, detached view surface; - the token remains the *transport*, persistence is layered on top. -- **Path-segment routes (`/dashboard/ws/`):** rejected — breaks - `isDashboardRoute`/`configBase`/OAuth-redirect, all keyed on the `/dashboard` - suffix. -- **A `?view=1` mode flag:** rejected in favor of store-membership discrimination - (not spoofable, and naturally yields the not-found case). - -## Addendum — 2026-07-23: stable workspace keys and collection persistence (#406) - -`StoredWorkspaceV2` replaces the single fixed `current` aggregate with one -IndexedDB record per workspace. Workspace identity is deliberately split: - -- `id` is the immutable opaque object-store key; -- `key` is the immutable, unique lowercase ASCII URL identity resolved by - `?ws=`; -- `name` is mutable display metadata and never rewrites bookmarks. - -The primary store uses `id` as its key path and a unique `key` index. Create, -replace, delete, and last-opened metadata updates are transaction-scoped; -replace cannot create a missing record or alter its key. Portable workspace -import creates a fresh local ID/key instead of replacing the active workspace. - -Implicit opens persist the successfully opened key in a separate preferences -store and stamp store-owned `lastOpenedAt` metadata using the injected wall -clock. If that preference is absent or invalid, resolution chooses the valid -workspace with the newest timestamp, breaking ties by key; records without a -timestamp fall back deterministically by key. Explicit `?ws=` opens never fall -back to another workspace. - -The detached-view store follows the same key-based `?ws=` resolution contract, -while its generated local key remains separate from its opaque detached ID. +- **Durable detached snapshots:** rejected because they silently diverge from + the live workspace and require duplicate persistence and transport. +- **New-tab navigation by default:** rejected because it encourages concurrent + editing and makes Back navigation ineffective. +- **Path-segment routes:** rejected because workspace, surface, and mode are + independent application state and query parameters preserve the single SPA + handler and OAuth redirect. +- **View as authorization:** out of scope. View mode is a local presentation + choice, not an access-control boundary. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fdff9dc1..31a8e936 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -44,7 +44,7 @@ service is tested with plain stubs at the per-file coverage gate. | Module | Owns | |---|---| | `query-execution-service` (`app.exec`) | the shared request/stream/normalize read core + the script transport loop (retry classification, stop-on-first-failure, per-attempt `query_id`); stateless `kill(queryId)` — cancellation is caller-owned (`AbortController`s live with the owning session) | -| `connection-session` (`app.conn`) | auth + connection lifecycle: OAuth PKCE login/refresh, Basic probing, IdP config, identity, token storage, sign-out, the cross-tab dashboard auth handoff, and **the single live `chCtx` object** (mutated in place — `authConfirmed` by `net/ch-client`, `origin` by sign-in/handoff — never reconstructed) | +| `connection-session` (`app.conn`) | auth + connection lifecycle: OAuth PKCE login/refresh, Basic probing, IdP config, identity, token storage, sign-out, and **the single live `chCtx` object** (mutated in place — `authConfirmed` by `net/ch-client`, `origin` by sign-in — never reconstructed) | | `schema-catalog-service` (`app.catalog`) | server version, schema tree, lazy columns, SQL reference/completions, entity-doc cache, `invalidate()` | | `workbench-parameter-session` (`app.params`) | `{name:Type}` analysis/prepare/gate policy, input-vs-execute hardening, enum inference, recent values; reads the live shared `AppState` slices through accessors | | `export-service` (`app.exports`) | direct + script export behind an injectable `ExportSink` (`pickFile`/`pickDirectory`); hold-back exception inspection, `.partial` semantics, its own cancellation state | diff --git a/src/application/connection-session.ts b/src/application/connection-session.ts index 6085b68a..9180b78e 100644 --- a/src/application/connection-session.ts +++ b/src/application/connection-session.ts @@ -1,28 +1,20 @@ // #276 Phase 2's ConnectionSession — auth + config + ClickHouse connection -// lifecycle (OAuth PKCE login/refresh, Basic probing, IdP config resolution, -// cross-tab auth handoff), constructible without App/AppState/DOM; no imports +// lifecycle (OAuth PKCE login/refresh, Basic probing, IdP config resolution), +// constructible without App/AppState/DOM; no imports // from src/ui/** or src/editor/** (check:arch enforces). Rendering is the // shell's job — auth loss surfaces through the injected `onAuthLost` callback, -// never a render/toast call. This is a byte-for-byte port of the auth/config/ -// connection lifecycle that used to live inline in src/ui/app.ts (see that -// file's history around the OAuth/basic-auth/handoff sections) — comments +// never a render/toast call. This is a port of the auth/config/connection +// lifecycle that used to live inline in src/ui/app.ts — comments // below are carried over, adapted only where the code itself moved (e.g. // `app.token` becomes a closed-over local, `ss`/`loc`/`win` become the // injected `deps.storage`/`deps.location`/`deps.win`, and a couple of // `app.renderApp()`/`renderLoginApp()` calls are gone because rendering isn't -// this module's job any more — see each call site below for the specific -// note). `openDashboard` itself (opening the new tab) stays app-side; this -// module only owns the credential-grant half of the handoff. +// this module's job any more — see each call site below for the specific note). import { decodeJwtPayload, isTokenExpired } from '../core/jwt.js'; import { generatePKCE, randomState } from '../core/pkce.js'; import type { PkceCrypto } from '../core/pkce.js'; -import { configBase } from '../core/dashboard.js'; import { resolveTarget } from '../core/target.js'; -import { - snapshotAuth, restoreAuth, hasAuth, isAuthRequest, isAuthGrant, AUTH_REQUEST, AUTH_GRANT, -} from '../core/auth-handoff.js'; -import type { AuthSnapshot, StorageLike } from '../core/auth-handoff.js'; import { buildAuthorizeUrl, refreshTokens, bearerFromTokens } from '../net/oauth.js'; import { memoizeConfig, loadConfigDoc, resolveIdp } from '../net/oauth-config.js'; import type { ConfigDoc, ResolvedIdpConfig, ChAuthKind } from '../net/oauth-config.js'; @@ -30,10 +22,10 @@ import type { ChCtx as NetChCtx, queryJson } from '../net/ch-client.js'; // ── Injected dependency seam ───────────────────────────────────────────────── -/** The sessionStorage-like surface this module needs — `core/auth-handoff.js`'s - * canonical `StorageLike` plus `removeItem` (the token/handoff bookkeeping - * below removes one-shot keys alongside `getItem`/`setItem`). */ -export interface SessionStorageLike extends StorageLike { +/** The sessionStorage-like surface used by OAuth PKCE token bookkeeping. */ +export interface SessionStorageLike { + getItem(key: string): string | null; + setItem(key: string, value: string): void; removeItem(key: string): void; } @@ -46,29 +38,15 @@ export type SessionCrypto = PkceCrypto; /** Every side effect this session needs, injected as a narrow bag — mirrors * `query-execution-service.ts`'s own `QueryExecutionDeps` seam. Production - * wires the real browser/env objects (`main.js`'s bootstrap resolves - * `handoffMs`/`handoffListenMs` defaults before constructing the session — - * this module never defaults them itself); tests inject plain stubs. */ + * wires the real browser/env objects; tests inject plain stubs. */ export interface ConnectionSessionDeps { fetch: typeof fetch; storage: SessionStorageLike; /** `href` is WRITTEN (the OAuth redirect assigns it) as well as read. */ location: { origin: string; pathname: string; search: string; href: string }; crypto: SessionCrypto; - /** Cross-tab auth-handoff listeners/timeouts. */ - win: Pick; /** Basic-auth sign-in probe. */ queryJson: typeof queryJson; - /** The child's own wait for a grant once it asks (a same-origin reply is - * near-instant, so this is short) — resolved by the caller; the historical - * 4000ms env default stays app-side. */ - handoffMs: number; - /** How long the opener keeps listening for a request — far longer than - * `handoffMs` because it must survive the child's cold JS load before the - * child can even ask; a short opener window would drop a slow tab's - * request and force a needless re-login. The historical 30000ms env - * default stays app-side. */ - handoffListenMs: number; /** Auth was lost (no token / expired-and-unrefreshable / CH rejected a * valid login). The session never renders — it calls this and lets the * shell decide how to show the login screen. */ @@ -134,13 +112,10 @@ export interface ConnectionSession { signOut(): void; ensureFreshToken(): Promise; - // cross-tab handoff (message contract: core/auth-handoff) - grantHandoffTo(child: Window): void; - receiveAuthHandoff(env: { opener?: Window | null }): Promise; } export function createConnectionSession(deps: ConnectionSessionDeps): ConnectionSession { - const { storage: ss, location: loc, crypto: cryptoObj, fetch: fetchFn, win, queryJson: queryJsonFn } = deps; + const { storage: ss, location: loc, crypto: cryptoObj, fetch: fetchFn, queryJson: queryJsonFn } = deps; // Two ways to be signed in: OAuth (a JWT bearer, the default) or 'basic' — // a ClickHouse username/password sent as Authorization: Basic, optionally @@ -156,12 +131,7 @@ export function createConnectionSession(deps: ConnectionSessionDeps): Connection // 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. - // 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. - const basePath = configBase(loc.pathname); + const basePath = loc.pathname.replace(/\/+$/, ''); const loadDoc = memoizeConfig(() => loadConfigDoc(fetchFn, basePath)); const resolvedCache = new Map>(); let idpId: string | null = ss.getItem('oauth_idp') || null; @@ -220,7 +190,7 @@ export function createConnectionSession(deps: ConnectionSessionDeps): Connection authMode = 'oauth'; chCtx.origin = loc.origin; chCtx.authConfirmed = false; // a fresh sign-in starts unconfirmed again - ['oauth_id_token', 'oauth_refresh_token', 'oauth_verifier', 'oauth_state', 'oauth_idp', 'oauth_origin', + ['oauth_id_token', 'oauth_refresh_token', 'oauth_verifier', 'oauth_state', 'oauth_return_route', 'oauth_idp', 'oauth_origin', 'ch_basic_auth', 'ch_basic_user', 'ch_basic_origin'].forEach((k) => ss.removeItem(k)); } // `signOut` is exactly today's app.ts `clearTokens()` — no render. (app.ts's @@ -241,8 +211,15 @@ export function createConnectionSession(deps: ConnectionSessionDeps): Connection const state = randomState(cryptoObj); ss.setItem('oauth_verifier', verifier); ss.setItem('oauth_state', state); + const returnParams = new URLSearchParams(loc.search); + ['code', 'state', 'scope', 'authuser', 'prompt', 'error', 'error_description', 'error_uri'] + .forEach((key) => returnParams.delete(key)); + const returnSearch = returnParams.toString(); + ss.setItem('oauth_return_route', JSON.stringify({ + state, search: returnSearch ? `?${returnSearch}` : '', + })); loc.href = buildAuthorizeUrl(cfg, { - redirectUri: loc.origin + loc.pathname, + redirectUri: loc.origin + basePath, challenge, state, }); @@ -363,77 +340,6 @@ export function createConnectionSession(deps: ConnectionSessionDeps): Connection return !!(await getToken()); } - // 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) side 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 `handoffMs` for a grant once it *asks* (a - // same-origin reply is near-instant, so this is short); the opener listens far - // longer (`handoffListenMs`) 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. - function grantHandoffTo(child: Window): void { - const onMsg = (e: MessageEvent): void => { - 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), deps.handoffListenMs); - } - - // 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: AuthSnapshot): void { - restoreAuth(ss, creds); - if (creds.ch_basic_auth) { - 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) 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). - function receiveAuthHandoff(handoffEnv: { opener?: Window | null }): Promise { - return new Promise((resolve) => { - const opener = handoffEnv.opener; - if (!opener) { resolve(false); return; } - let done = false; - const finish = (ok: boolean): void => { - if (done) return; - done = true; - win.removeEventListener('message', onMsg); - resolve(ok); - }; - const onMsg = (e: MessageEvent): void => { - if (!isAuthGrant(e, loc.origin, opener)) return; - // `e.data` is a real handoff grant `{type, creds}` once isAuthGrant has - // confirmed `type` — `AuthMessageEvent`'s own contract type only pins - // `type` (the shared predicate's whole point); `creds` rides alongside. - const data = e.data as { type?: string; creds?: AuthSnapshot } | null; - // 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(data?.creds)) return; - applyAuthSnapshot(data!.creds!); - finish(true); - }; - win.addEventListener('message', onMsg); - opener.postMessage({ type: AUTH_REQUEST }, loc.origin); - win.setTimeout(() => finish(false), deps.handoffMs); - }); - } - return { basePath, hostHint, @@ -461,7 +367,5 @@ export function createConnectionSession(deps: ConnectionSessionDeps): Connection connectBasic, signOut, ensureFreshToken, - grantHandoffTo, - receiveAuthHandoff, }; } diff --git a/src/core/auth-handoff.ts b/src/core/auth-handoff.ts deleted file mode 100644 index b04aa5f2..00000000 --- a/src/core/auth-handoff.ts +++ /dev/null @@ -1,70 +0,0 @@ -// 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. - -/** A sessionStorage-like object: only the two methods this module uses. */ -export interface StorageLike { - getItem(key: string): string | null; - setItem(key: string, value: string): void; -} - -/** A snapshot of the present auth keys (only those that were set). */ -export type AuthSnapshot = Partial>; - -/** The sessionStorage keys that carry a live auth session (OAuth or basic). */ -export const AUTH_SS_KEYS: string[] = [ - '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: StorageLike): AuthSnapshot { - const snap: AuthSnapshot = {}; - 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: StorageLike, snap: AuthSnapshot | null | undefined): void { - 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: AuthSnapshot | null | undefined): boolean { - return !!(snap && (snap.oauth_id_token || snap.ch_basic_auth)); -} - -/** The subset of a MessageEvent this module's predicates read. */ -export interface AuthMessageEvent { - origin: string; - source: unknown; - data?: { type?: string } | null; -} - -/** A well-formed credential *request* from the expected origin + source window. */ -export function isAuthRequest(e: AuthMessageEvent | null | undefined, origin: string, source: unknown): boolean { - 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: AuthMessageEvent | null | undefined, origin: string, source: unknown): boolean { - return !!e && e.origin === origin && e.source === source - && !!e.data && e.data.type === AUTH_GRANT; -} diff --git a/src/core/dashboard.ts b/src/core/dashboard.ts index bc8fa392..1b37d41d 100644 --- a/src/core/dashboard.ts +++ b/src/core/dashboard.ts @@ -1,33 +1,12 @@ // 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 and the tile result caps. +// new schema. This module holds legacy layout preference helpers and tile caps. // (Per-tile classification moved to core/panel-cfg.js's autoPanel/resolvePanel // in #166 — the panel union replaced classifyTile's chart-vs-skip ladder. The // tiles stream through the shared `app.exec.executeRead` seam as of #193/#276, so the // former `FORMAT JSON` → array-rows transform and its SQL prep were retired.) -/** - * 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?: string | null): boolean { - 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?: string | null): string { - return (pathname || '').replace(/\/dashboard\/?$/, ''); -} - /** * Dashboard layout modes (#149 D2, #184): `arrange` = uniform multi-column grid * (default, column count from `dashCols`), `report` = single centered column diff --git a/src/core/handoff-token.ts b/src/core/handoff-token.ts deleted file mode 100644 index 41559ced..00000000 --- a/src/core/handoff-token.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Pure token generation for the one-time dashboard view handoff (#288/#302). -// No DOM, no globals — `crypto` is injected exactly like the OAuth PKCE -// verifier/state generation in `src/net/oauth.ts`, so this stays testable -// without a real `crypto` global. - -const HEX = '0123456789abcdef'; - -/** - * An unguessable 256-bit token, hex-encoded (64 lowercase hex chars). Backs - * the one-time `?st=` handoff URL param: the opener writes a `HandoffRecord` - * keyed by this token, the new tab consumes+deletes it exactly once. - * - * `crypto` is injected (the caller passes `env.crypto` or the real - * `globalThis.crypto`) so tests can supply a deterministic fake. Reads exactly - * 32 bytes via `getRandomValues(new Uint8Array(32))`. - */ -export function randomHandoffToken(crypto: Pick): string { - const bytes = new Uint8Array(32); - crypto.getRandomValues(bytes); - let hex = ''; - for (let i = 0; i < bytes.length; i++) { - const byte = bytes[i]; - hex += HEX[(byte >> 4) & 0xf] + HEX[byte & 0xf]; - } - return hex; -} diff --git a/src/core/sql-route.ts b/src/core/sql-route.ts new file mode 100644 index 00000000..041bf8fe --- /dev/null +++ b/src/core/sql-route.ts @@ -0,0 +1,55 @@ +// Pure parser/builder for the single `/sql` application route (#407). +// The caller supplies `location.search`; this module owns only `ws`, `surface`, +// and `mode`, leaving OAuth callback and other application parameters intact. + +export type SqlRoute = + | { surface: 'workspace'; workspaceKey: string | null } + | { surface: 'dashboard'; workspaceKey: string | null; mode: 'edit' | 'view' }; + +/** Parse route-owned parameters into their normalized application meaning. */ +export function parseSqlRoute(search: string): SqlRoute { + const params = new URLSearchParams(search); + const workspaceKey = params.has('ws') ? (params.get('ws') ?? '') : null; + if (params.get('surface') === 'dashboard') { + return { + surface: 'dashboard', + workspaceKey, + mode: params.get('mode') === 'view' ? 'view' : 'edit', + }; + } + return { surface: 'workspace', workspaceKey }; +} + +/** + * Build a canonical search string for `route`, preserving every parameter this + * route contract does not own. The result is empty or begins with `?`. + */ +export function buildSqlRouteSearch(route: SqlRoute, currentSearch = ''): string { + const params = new URLSearchParams(currentSearch); + params.delete('ws'); + params.delete('surface'); + params.delete('mode'); + // Retired Dashboard snapshot route state (#407); never carry it forward. + params.delete('st'); + params.delete('dash'); + if (route.workspaceKey !== null) params.set('ws', route.workspaceKey); + if (route.surface === 'dashboard') { + params.set('surface', 'dashboard'); + if (route.mode === 'view') params.set('mode', 'view'); + } + const encoded = params.toString(); + return encoded ? `?${encoded}` : ''; +} + +/** Parse and canonicalize a raw search string in one step. */ +export function normalizeSqlRouteSearch(search: string): { route: SqlRoute; search: string } { + const route = parseSqlRoute(search); + return { route, search: buildSqlRouteSearch(route, search) }; +} + +/** Return the same route pointed at another workspace key. */ +export function routeForWorkspace(route: SqlRoute, workspaceKey: string): SqlRoute { + return route.surface === 'dashboard' + ? { surface: 'dashboard', workspaceKey, mode: route.mode } + : { surface: 'workspace', workspaceKey }; +} diff --git a/src/dashboard/application/dashboard-open-source.ts b/src/dashboard/application/dashboard-open-source.ts deleted file mode 100644 index a6ce585e..00000000 --- a/src/dashboard/application/dashboard-open-source.ts +++ /dev/null @@ -1,57 +0,0 @@ -// Pure parsing/building of the standalone Dashboard route's open-source -// contract (#288/#302). No DOM, no globals — the caller supplies the raw URL -// search string (e.g. `location.search`) and gets back a discriminated union -// telling it which store to read the dashboard from. -// -// Two ways a dashboard tab can be opened (see the coordinator's two-mode -// model): EDIT mode resolves `?ws=&dash=` against the -// shared primary workspace store; VIEW mode's one-time handoff resolves -// `?st=&dash=` against the dedicated handoff store, then -// rewrites the URL to a persistent `?ws=` once materialized. `st` -// takes precedence over `ws` when both are present (a stale `ws` left over -// from history should never shadow a fresh handoff token). - -/** Which store a standalone Dashboard tab should resolve its dashboard from. */ -export type DashboardOpenSource = - | { kind: 'current-workspace'; workspaceKey: string; dashboardId: string } - | { kind: 'session-bundle'; token: string; dashboardId: string }; - -/** - * Parse a URL search string (e.g. `location.search`, with or without a leading - * `?`) into a `DashboardOpenSource`. Precedence: a non-empty `st` param wins as - * `session-bundle`; else a non-empty `ws` param is `current-workspace`; else - * `null` (the legacy bare `/dashboard` open, or garbage input). A missing - * `dash` param becomes `''` rather than failing parse — the discriminator only - * needs to know WHICH store to look in; a missing dashboard id inside that - * store is the caller's not-found case to handle. - * - * `state` is deliberately never read here — it is main.ts's OAuth CSRF param, - * and this contract does not use that name for its token (see the coordinator - * spec's URL param naming rule). - */ -export function parseDashboardOpenSource(search: string): DashboardOpenSource | null { - const params = new URLSearchParams(search); - const dashboardId = params.get('dash') ?? ''; - const token = params.get('st'); - if (token) return { kind: 'session-bundle', token, dashboardId }; - const workspaceKey = params.get('ws'); - if (workspaceKey) return { kind: 'current-workspace', workspaceKey, dashboardId }; - return null; -} - -/** - * Build the `?`-prefixed search string for a `DashboardOpenSource`, the - * inverse of `parseDashboardOpenSource`. `current-workspace` → `?ws=..&dash=..`; - * `session-bundle` → `?st=..&dash=..`. Values are URL-encoded by - * `URLSearchParams`. - */ -export function buildDashboardSearch(source: DashboardOpenSource): string { - const params = new URLSearchParams(); - if (source.kind === 'session-bundle') { - params.set('st', source.token); - } else { - params.set('ws', source.workspaceKey); - } - params.set('dash', source.dashboardId); - return '?' + params.toString(); -} diff --git a/src/dashboard/application/session-bundle.ts b/src/dashboard/application/session-bundle.ts deleted file mode 100644 index 3c8a0f8b..00000000 --- a/src/dashboard/application/session-bundle.ts +++ /dev/null @@ -1,125 +0,0 @@ -// Pure application glue for the standalone Dashboard route's view-mode -// session-bundle handoff (#288). Ties together the open-source contract -// (dashboard-open-source.ts), the one-time handoff record shape -// (workspace/handoff-store.types.ts), and the portable-bundle export/codec -// pair (dashboard-export.ts / portable-bundle-codec.ts) into three pure -// functions the coordinator (app.ts/dashboard.ts) calls around its impure -// IndexedDB/crypto/clock seams. -// -// NO DOM, NO globals, NO IndexedDB — those live in the coordinator. This -// module obeys the same import boundary as every other `dashboard/application` -// file: only `src/dashboard/**`, `src/workspace/**` types, `src/core/**`, and -// `src/generated/**`. - -import { buildDashboardExportBundle } from '../model/dashboard-export.js'; -import { decodePortableBundleJson, encodePortableBundleJson } from '../model/portable-bundle-codec.js'; -import { diagnostic } from '../model/workspace-diagnostics.js'; -import type { WorkspaceDiagnostic } from '../model/workspace-diagnostics.js'; -import type { HandoffRecord } from '../../workspace/handoff-store.types.js'; -import type { DashboardOpenSource } from './dashboard-open-source.js'; -import type { - DashboardDocumentV1, PortableBundleV1, SavedQueryV2, StoredWorkspaceV2, -} from '../../generated/json-schema.types.js'; -import { deriveWorkspaceKey, normalizeWorkspaceKeyLookup } from '../../core/workspace-key.js'; - -export type BuildViewHandoffRecordResult = - | { ok: true; record: HandoffRecord } - | { ok: false; diagnostics: WorkspaceDiagnostic[] }; - -/** - * Build the one-time handoff record for opening a Dashboard in view mode: - * snapshot `dashboard` plus its dependency closure (`buildDashboardExportBundle`) - * into a portable bundle, canonically encode it, and assemble the record the - * coordinator writes to the handoff store. `nowISO`/`expiresAt`/ - * `detachedWorkspaceId` are all supplied by the caller — every impure source - * (clock, id generator) lives in app.ts, never here. - */ -export function buildViewHandoffRecord( - dashboard: DashboardDocumentV1, - queries: readonly SavedQueryV2[], - opts: { detachedWorkspaceId: string; expiresAt: number; nowISO: string }, -): BuildViewHandoffRecordResult { - const bundle = buildDashboardExportBundle(dashboard, queries, opts.nowISO); - const encoded = encodePortableBundleJson({ - queries: bundle.queries, dashboards: bundle.dashboards, nowISO: bundle.exportedAt, - }); - if (!encoded.ok) return { ok: false, diagnostics: encoded.diagnostics }; - return { - ok: true, - record: { - text: encoded.value, - dashboardId: dashboard.id, - detachedWorkspaceId: opts.detachedWorkspaceId, - expiresAt: opts.expiresAt, - }, - }; -} - -export type MaterializeDetachedWorkspaceResult = - | { ok: true; workspace: StoredWorkspaceV2 } - | { ok: false; diagnostics: WorkspaceDiagnostic[] }; - -/** - * Materialize a consumed handoff record's bundle text into a detached, - * read-only `StoredWorkspaceV2`. Decodes and validates `text`, selects the - * Dashboard matching `dashboardId` out of the bundle (failing with a single - * diagnostic when absent), and assembles the detached workspace under - * `detachedWorkspaceId`. `name` is the selected Dashboard's title, falling - * back to `'Dashboard'` when the title is empty. - */ -export function materializeDetachedWorkspace( - text: string, dashboardId: string, detachedWorkspaceId: string, -): MaterializeDetachedWorkspaceResult { - const decoded = decodePortableBundleJson(text); - if (!decoded.ok) return { ok: false, diagnostics: decoded.diagnostics }; - const bundle: PortableBundleV1 = decoded.value; - const selected = bundle.dashboards.find((candidate) => candidate.id === dashboardId); - if (!selected) { - return { - ok: false, - diagnostics: [diagnostic(['dashboards'], 'dashboard-not-found', `Dashboard ${dashboardId} not found in bundle`)], - }; - } - return { - ok: true, - workspace: { - storageVersion: 2, - id: detachedWorkspaceId, - key: deriveWorkspaceKey(detachedWorkspaceId), - name: selected.title || 'Dashboard', - queries: bundle.queries, - dashboard: selected, - }, - }; -} - -/** Pure mode resolution result for a parsed `current-workspace` open source. */ -export type DashboardModeResolution = - | { mode: 'edit'; workspace: StoredWorkspaceV2 } - | { mode: 'view'; workspace: StoredWorkspaceV2 } - | { mode: 'not-found' }; - -const matches = ( - workspace: StoredWorkspaceV2 | null, source: Extract, -): workspace is StoredWorkspaceV2 => - !!workspace - && workspace.key === normalizeWorkspaceKeyLookup(source.workspaceKey) - && workspace.dashboard?.id === source.dashboardId; - -/** - * Resolve a parsed `current-workspace` `DashboardOpenSource` against the two - * candidate workspaces already loaded by the coordinator: `primary` (from the - * shared `asb-workspaces-v2` store) and `detached` (from the `asb-dashboard-views` - * detached-views store). Both the workspace key AND the Dashboard id must - * match — a workspace-key match with a differing (or missing) Dashboard id is - * `not-found`, never a silent fall-through to edit mode. - */ -export function resolveDashboardMode( - source: Extract, - primary: StoredWorkspaceV2 | null, - detached: StoredWorkspaceV2 | null, -): DashboardModeResolution { - if (matches(primary, source)) return { mode: 'edit', workspace: primary }; - if (matches(detached, source)) return { mode: 'view', workspace: detached }; - return { mode: 'not-found' }; -} diff --git a/src/env.types.ts b/src/env.types.ts index 131f876f..31ea8532 100644 --- a/src/env.types.ts +++ b/src/env.types.ts @@ -77,8 +77,6 @@ export interface CreateAppEnv { * `{ clipboard: { writeText } }` or omits the field entirely. */ navigator?: { clipboard?: Clipboard } & Record; download?: (filename: string, mime: string, content: BlobPart) => void; - handoffMs?: number; - handoffListenMs?: number; } /** The env param of `bootstrap(app, env)` — a distinct, fully-required shape @@ -88,5 +86,4 @@ export interface BootstrapEnv { sessionStorage: Storage; history: History; fetch: typeof fetch; - opener?: Window | null; } diff --git a/src/main.ts b/src/main.ts index c4b5072a..71487353 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,7 +18,7 @@ import { handleKeydown } from './ui/shortcuts.js'; import { exchangeCodeForTokens, bearerFromTokens } from './net/oauth.js'; import { decodeShare } from './core/share.js'; import { cloneJson, queryName, queryPanel, queryView, upgradeSavedQuery } from './core/saved-query.js'; -import { isDashboardRoute } from './core/dashboard.js'; +import { parseSqlRoute } from './core/sql-route.js'; import { rolePreviewView } from './core/result-choice.js'; import { isQuerylessPanel } from './core/panel-cfg.js'; import { setTabSpecDraft, SAVED_VIEWS } from './state.js'; @@ -39,9 +39,9 @@ import type { ShortcutKeydownEvent } from './ui/shortcuts.js'; export interface BootstrapApp { state: Pick; conn: Pick; - renderDashboard(): void; - renderApp(): void; + 'basePath' | 'isSignedIn' | 'resolveConfig' | 'setTokens' | 'ensureConfig'>; + renderCurrentSurface(): void; + syncSqlRoute(search: string): void; /** The real `App.showLogin` is `(msg?: string) => void` — every other real * caller (ui/login.ts) always passes a string. `callbackError` below is * main.ts's own `string | null` sentinel (`null` means "no callback @@ -62,13 +62,11 @@ export async function bootstrap(app: BootstrapApp, env: BootstrapEnv): Promise<{ 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); + let dash = parseSqlRoute(loc.search).surface === 'dashboard'; const u = new URL(loc.href); const code = u.searchParams.get('code'); const stateParam = u.searchParams.get('state'); + const expectedState = ss.getItem('oauth_state'); const errorParam = u.searchParams.get('error'); let callbackError: string | null = null; @@ -77,7 +75,7 @@ export async function bootstrap(app: BootstrapApp, env: BootstrapEnv): Promise<{ // a code — surface it rather than dropping silently onto the login screen. callbackError = 'Sign-in failed: ' + (u.searchParams.get('error_description') || errorParam); } else if (code && stateParam) { - if (stateParam !== ss.getItem('oauth_state')) { + if (stateParam !== expectedState) { callbackError = 'OAuth state mismatch — please try again.'; } else { try { @@ -89,7 +87,7 @@ export async function bootstrap(app: BootstrapApp, env: BootstrapEnv): Promise<{ // behavior guard) keeps the exact pre-existing pass-through-null // runtime shape for a stale/direct hit with no stashed verifier. verifier: ss.getItem('oauth_verifier') as string, - redirectUri: loc.origin + loc.pathname, + redirectUri: loc.origin + app.conn.basePath, }); const bearer = bearerFromTokens(tokens, cfg.bearer); if (!bearer) throw new Error('Token response missing bearer token'); @@ -102,8 +100,27 @@ export async function bootstrap(app: BootstrapApp, env: BootstrapEnv): Promise<{ if (errorParam || (code && stateParam)) { ['code', 'state', 'scope', 'authuser', 'prompt', 'error', 'error_description', 'error_uri'] .forEach((k) => u.searchParams.delete(k)); + if (stateParam && stateParam === expectedState) { + try { + const saved = JSON.parse(ss.getItem('oauth_return_route') || 'null') as { + state?: unknown; search?: unknown; + } | null; + if (saved?.state === stateParam && typeof saved.search === 'string') { + const restored = new URLSearchParams(saved.search); + for (const key of new Set(restored.keys())) u.searchParams.delete(key); + for (const [key, value] of restored) u.searchParams.append(key, value); + ss.removeItem('oauth_return_route'); + } + } catch { + // Invalid same-tab return metadata is ignored; callback validation and + // the normal implicit route remain authoritative. + } + } const qs = u.searchParams.toString(); - hist.replaceState(null, '', loc.origin + loc.pathname + (qs ? '?' + qs : '') + loc.hash); + const cleanedSearch = qs ? '?' + qs : ''; + hist.replaceState(null, '', loc.origin + loc.pathname + cleanedSearch + loc.hash); + app.syncSqlRoute(cleanedSearch); + dash = parseSqlRoute(cleanedSearch).surface === 'dashboard'; } // A shared query (SQL + complete Spec) rides in the URL hash, which is lost @@ -160,18 +177,6 @@ export async function bootstrap(app: BootstrapApp, env: BootstrapEnv): Promise<{ } } - // 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.conn.isSignedIn()) { - await app.conn.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.conn.isSignedIn()) await app.conn.ensureFreshToken(); - } - if (app.conn.isSignedIn()) { // Signed in either via a valid OAuth token or a restored basic session. ss.removeItem('oauth_shared'); // consumed @@ -179,15 +184,8 @@ export async function bootstrap(app: BootstrapApp, env: BootstrapEnv): Promise<{ // ch_auth=basic username, not the raw email claim) on first paint. // (ensureConfig is a no-op in basic mode.) await app.conn.ensureConfig(); - if (dash) app.renderDashboard(); - else { - // #287 W4: resolve + project the aggregate before the Workbench's first - // paint so the saved-query sidebar/Save flow already treat it as the - // single source of truth (the /dashboard branch above keeps resolving - // its own copy via `renderDashboard` → `loadDashboardWorkspace`). - await app.loadWorkspaceOnBoot(); - app.renderApp(); - } + await app.loadWorkspaceOnBoot(); + app.renderCurrentSurface(); } else { app.showLogin(callbackError); } @@ -227,7 +225,6 @@ 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/styles.css b/src/styles.css index 0115f424..730a02fd 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2482,9 +2482,8 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } .docs-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. */ +/* ── Dashboard (#149 D1 / #407) ───────────────────────────────────────────── + The `/sql?surface=dashboard` surface: sticky header + responsive tiles. */ /* 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); } @@ -2496,6 +2495,19 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } gap: 12px; padding: 11px 20px; background: var(--bg-header); border-bottom: 1px solid var(--border); flex-wrap: wrap; } +.dash-route-controls { display: inline-flex; align-items: center; gap: 8px; } +.dash-route-switch { + display: inline-flex; align-items: center; padding: 2px; gap: 2px; + border: 1px solid var(--border); border-radius: 7px; background: var(--bg); +} +.dash-route-btn { + min-height: 26px; padding: 3px 9px; border: 0; border-radius: 5px; + background: transparent; color: var(--fg-muted); font: inherit; cursor: pointer; +} +.dash-route-btn:hover { background: var(--bg-hover); color: var(--fg); } +.dash-route-btn[aria-pressed="true"] { + background: var(--accent-bg); color: var(--accent-fg); font-weight: 600; +} /* Filter toolbar (#149 D2, now filters-only since the layout switcher moved to the header — 2026-07-18). `flex-wrap: nowrap` at every width (#294): filters used to wrap the toolbar onto extra rows once several `{name:Type}` params diff --git a/src/ui/app.ts b/src/ui/app.ts index 24b0e816..c4f9dae2 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -39,7 +39,7 @@ import type { QueryOrName } from './tabs.js'; import { batch } from '@preact/signals-core'; import { renderResults } from './results.js'; import type { Result, QueryResult, ScriptResult, ScriptEntry } from './results.js'; -import { renderDashboard } from './dashboard.js'; +import { disposeDashboardSurface, renderDashboard } from './dashboard.js'; import { toggleThemeDom } from './theme-toggle.js'; import { openSchemaView } from './explain-graph.js'; import type { SchemaLineageNode, DetachedGraphApp } from './explain-graph.js'; @@ -77,17 +77,17 @@ import { createAppPreferences } from '../application/app-preferences.js'; import { createWorkspaceRepository } from '../workspace/workspace-repository.js'; import type { WorkspaceLoadResult } from '../workspace/workspace-repository.js'; import { createIndexedDbWorkspaceStore } from '../workspace/indexeddb-workspace-store.js'; -import { createIndexedDbHandoffStore } from '../workspace/indexeddb-handoff-store.js'; -import { createIndexedDbDetachedViewsStore } from '../workspace/indexeddb-detached-views-store.js'; import { createNewWorkspace, DEFAULT_WORKSPACE_NAME } from '../workspace/workspace-operations.js'; import { deriveWorkspaceKey } from '../core/workspace-key.js'; import { workspaceToken, queryToken, queriesChanged } from '../workspace/workspace-sync.js'; import { buildConflictChooser } from './conflict-resolution.js'; -import { isDashboardRoute } from '../core/dashboard.js'; -import { parseDashboardOpenSource, buildDashboardSearch } from '../dashboard/application/dashboard-open-source.js'; -import { buildViewHandoffRecord, materializeDetachedWorkspace } from '../dashboard/application/session-bundle.js'; -import { randomHandoffToken } from '../core/handoff-token.js'; -import { exportDashboardAction, triggerImportDashboard, renderDashboardNav } from './file-menu.js'; +import { + buildSqlRouteSearch, normalizeSqlRouteSearch, parseSqlRoute, routeForWorkspace, +} from '../core/sql-route.js'; +import type { SqlRoute } from '../core/sql-route.js'; +import { + disposeFileMenuOverlays, exportDashboardAction, triggerImportDashboard, renderDashboardNav, +} from './file-menu.js'; import { createWorkbenchSession } from './workbench/workbench-session.js'; import { createQueryDocumentSession } from '../application/query-document-session.js'; import { createSavedQueryService } from '../application/saved-query-service.js'; @@ -128,12 +128,6 @@ interface WindowExtras { * `registerSpecValidator` action) that other modules never call directly. */ type AppSpecValidators = QuerySpecValidationService; -/** #288 Phase 6 — how long a one-time view-mode handoff token stays consumable - * (ADR-0003). Long enough to survive a cold viewer tab's OAuth round-trip, - * short enough to bound exposure; the real guarantee is delete-on-consume, not - * the TTL. 5 minutes. */ -const VIEW_HANDOFF_TTL_MS = 5 * 60_000; - export function createApp(env: CreateAppEnv = {}): App { const doc = env.document || document; const win = (env.window || window) as Window & WindowExtras; @@ -233,28 +227,14 @@ export function createApp(env: CreateAppEnv = {}): App { // this phase; wiring reads onto the aggregate is Phases 3-6 of #280. const workspaceStore = createIndexedDbWorkspaceStore(env.indexedDB || win.indexedDB); app.workspace = createWorkspaceRepository({ store: workspaceStore, now: wallNow }); - // #288 Phase 6 — Dashboard viewing. Two dedicated IndexedDB stores (own - // databases, same injected factory + lazy-open pattern as the workspace - // store): `handoff` is the one-time cross-tab token transport that carries a - // view-mode Dashboard snapshot to a new tab; `detachedViews` is the - // persistent store that the consumed handoff materializes into (a read-only - // copy under its own fresh workspace id, detached from the editable primary - // workspace — see ADR-0003). `dashboardOpenSource` is THIS tab's parsed - // /dashboard route: `?ws=&dash=` (edit, current-workspace) or `?st=&dash=` - // (a one-time view handoff), or null on a bare/legacy `/dashboard` open. - app.handoff = createIndexedDbHandoffStore(env.indexedDB || win.indexedDB); - app.detachedViews = createIndexedDbDetachedViewsStore(env.indexedDB || win.indexedDB); - app.dashboardOpenSource = parseDashboardOpenSource(loc.search); - // Static per-tab flag: is THIS tab the standalone `/dashboard` route (vs the - // Workbench)? Lets shared post-commit logic (`afterLibraryChange`) repaint the - // right surface — a Dashboard-page import re-renders the dashboard, not the - // absent Workbench chrome. - app.dashboardRoute = isDashboardRoute(loc.pathname); - // #343 step 6: set true by `renderDashboard` only while a DETACHED/read-only - // Dashboard is on screen. A Workbench tab (and an editable Dashboard) leaves it - // false, so the cross-tab refresh below projects normally; a detached view - // flips it so refresh skips projecting the primary workspace over its snapshot. - app.dashboardReadOnly = false; + // #407 — both application surfaces live on `/sql`; URL query parameters are + // parsed once here and reparsed on Back/Forward. The resolved live workspace + // is shared by Workbench and Dashboard. + let routeSearch = loc.search; + let routeLoadGeneration = 0; + app.sqlRoute = parseSqlRoute(routeSearch); + app.currentWorkspace = null; + app.workspaceRouteStatus = 'ready'; // The `{name:Type}` var-value/filter-active/recent-value persistence // wrappers (saveVarValues/saveFilterActive/saveVarRecent/ // saveVarRecentDisabled) + the recent-value policy that sits on top of them @@ -389,19 +369,14 @@ export function createApp(env: CreateAppEnv = {}): App { // either way). const renderLoginApp = (msg?: string): void => renderLogin(app as App & { root: Element }, msg); // The auth + config + ClickHouse connection lifecycle (#276 Phase 2) — OAuth - // PKCE login/refresh, Basic probing, IdP config resolution, and cross-tab - // auth handoff all now live in `application/connection-session.ts`, + // PKCE login/refresh, Basic probing, and IdP config resolution live in + // `application/connection-session.ts`, // constructible without App/AppState/DOM; this module wires it to the real // browser env and to `renderLoginApp` (the one piece that IS this shell's - // job — the session only ever calls `onAuthLost`, never renders). The two - // handoff windows (how long the child waits for a grant vs. how long the - // opener keeps listening) are env-injectable seams whose defaults are - // documented in full on `ConnectionSessionDeps` itself. + // job — the session only ever calls `onAuthLost`, never renders). const conn = createConnectionSession({ - fetch: fetchFn, storage: ss, location: loc, crypto: cryptoObj, win, + fetch: fetchFn, storage: ss, location: loc, crypto: cryptoObj, queryJson: ch.queryJson, - handoffMs: env.handoffMs != null ? env.handoffMs : 4000, - handoffListenMs: env.handoffListenMs != null ? env.handoffListenMs : 30000, onAuthLost: (detail) => renderLoginApp(detail), }); app.conn = conn; @@ -1526,13 +1501,31 @@ export function createApp(env: CreateAppEnv = {}): App { // `runSlotTile`), the same path run() and the detached Data view use; the // former bespoke `runTile`/`queryDashboardTile`/`parseJsonResult` machinery // was retired so cap/settings fixes can't apply to only one path. - app.renderDashboard = () => renderDashboard(app); + let disposeWorkbenchMount: (() => void) | null = null; + const ignoreExternalWorkspaceChange = (): void => {}; + app.renderDashboard = () => { + disposeFileMenuOverlays(app); + disposeWorkbenchMount?.(); + disposeWorkbenchMount = null; + workbench.destroy(); + return renderDashboard(app); + }; + const disposeCurrentSurface = (): void => { + disposeFileMenuOverlays(app); + disposeDashboardSurface(); + disposeWorkbenchMount?.(); + disposeWorkbenchMount = null; + workbench.destroy(); + app.onWorkspaceExternallyChanged = ignoreExternalWorkspaceChange; + }; // Project the active StoredWorkspaceV2 onto the current application surface. // Persistence is now a collection; this projection identifies which record // this tab is editing, while the repository remains independently addressable // by immutable id and stable URL key. const applyCommittedWorkspace = (workspace: StoredWorkspaceV2): void => { + app.currentWorkspace = workspace; + app.workspaceRouteStatus = 'ready'; const workspaceChanged = app.state.workspaceId !== workspace.id; if (workspaceChanged) detachWorkspaceBoundTabs(app.state); app.state.savedQueries = workspace.queries; @@ -1595,11 +1588,11 @@ export function createApp(env: CreateAppEnv = {}): App { let lastCommittedToken = ''; app.getLastCommittedToken = () => lastCommittedToken; // #343 step 4: the route/surface refresh hook a mounted route registers to - // react AFTER a refresh actually projected an external change — the standalone - // Dashboard route (a later step) overrides this to rebuild its viewer session + // react AFTER a refresh actually projected an external change — Dashboard + // overrides this to rebuild its viewer session // from the latest committed workspace. Default no-op: the Workbench route's // repaint is built into `refreshWorkspaceFromStore` itself. - app.onWorkspaceExternallyChanged = () => {}; + app.onWorkspaceExternallyChanged = ignoreExternalWorkspaceChange; // #343 §5: open the invalidation channel and route inbound pokes (that aren't // our own) to the hook. Never carries the workspace body — only a signal. const workspaceChannel = broadcastChannelFactory('asb:workspace'); @@ -1625,7 +1618,13 @@ export function createApp(env: CreateAppEnv = {}): App { if (loaded.status === 'corrupt') { return { ok: false as const, diagnostics: loaded.diagnostics }; } - const latest = loaded.status === 'ok' ? loaded.workspace : null; + if (loaded.status !== 'ok') { + app.currentWorkspace = null; + app.workspaceRouteStatus = 'not-found'; + app.renderCurrentSurface(); + return { ok: false as const, aborted: true as const }; + } + const latest = loaded.workspace; const input = await transform(latest); if (!input || !input.candidate) { return { ok: false as const, aborted: true as const, data: input ? input.data : undefined }; @@ -1661,34 +1660,37 @@ export function createApp(env: CreateAppEnv = {}): App { // projecting an older read over a newer local commit. A failed load keeps the // projection and warns; it never rejects the queued op (no wedge). const runWorkspaceRefresh = async (): Promise => { - // #343 step 6: a detached/read-only Dashboard renders from a detached - // snapshot, not the primary workspace — primary-workspace invalidation must - // never project over it (it would clobber `app.state` with a different - // aggregate). The Dashboard route sets `dashboardReadOnly` per render; a - // Workbench tab and an editable Dashboard leave it false. - if (app.dashboardReadOnly) return; + const requestedWorkspaceId = app.state.workspaceId; + const requestedRouteGeneration = routeLoadGeneration; let loaded: StoredWorkspaceV2 | null; try { - const result = await app.workspace.loadById(app.state.workspaceId); + const result = await app.workspace.loadById(requestedWorkspaceId); if (result.status === 'corrupt') { warnRefreshFailed(); return; } loaded = result.status === 'ok' ? result.workspace : null; } catch { warnRefreshFailed(); return; } + if (app.state.workspaceId !== requestedWorkspaceId + || routeLoadGeneration !== requestedRouteGeneration) return; // Unchanged since this tab's last projection ⇒ cheap no-op (the common case - // for an activation refresh that raced no real external write). An external - // clear (had a workspace, now empty) also leaves the projection untouched. - if (workspaceToken(loaded) === lastCommittedToken || !loaded) return; + // for an activation refresh that raced no real external write). + if (workspaceToken(loaded) === lastCommittedToken) return; + if (!loaded) { + app.currentWorkspace = null; + app.workspaceRouteStatus = 'not-found'; + app.renderCurrentSurface(); + return; + } // Reconcile linked tabs from the CURRENT (pre-projection) snapshots so the // orphan/detach distinction survives, THEN project committed truth (which // reconciles tab links + fills tokens + records lastCommittedToken). const queriesDidChange = queriesChanged(app.state.savedQueries, loaded.queries); reconcileLinkedTabsToLatest(app.state, loaded); applyCommittedWorkspace(loaded); - // Workbench surface repaint. The standalone Dashboard route has no Workbench - // chrome — it reacts through the `onWorkspaceExternallyChanged` hook instead. - if (!app.dashboardRoute) { + // Workbench surface repaint. Dashboard reacts through the + // `onWorkspaceExternallyChanged` hook instead. + if (app.sqlRoute.surface === 'workspace') { // Re-run the tab effect (editor doc re-sync for the active tab, parked // reconcile for the rest, tab strip + Save button + var strip) by handing // the tabs signal a fresh array reference. @@ -1758,18 +1760,6 @@ export function createApp(env: CreateAppEnv = {}): App { } }; - app.loadDashboardWorkspace = async (key?: string, dashboardId?: string) => { - const result = key - ? await app.workspace.loadByKey(key) - : await resolveImplicitOrProvision(); - if (result.status !== 'ok') return null; - // A keyed Dashboard route is valid only when both parts of its identity - // resolve. Do not make a workspace "last used" for a not-found Dashboard. - if (dashboardId !== undefined && result.workspace.dashboard?.id !== dashboardId) return null; - await recordOpened(result.workspace); - return result.workspace; - }; - const resetCorruptWorkspace = async (id: string): Promise => { const deleted = await app.workspace.delete(id); if (!deleted.ok) return; @@ -1777,17 +1767,31 @@ export function createApp(env: CreateAppEnv = {}): App { if (result.status === 'ok') { applyCommittedWorkspace(result.workspace); await recordOpened(result.workspace); - app.renderApp(); + app.sqlRoute = routeForWorkspace(app.sqlRoute, result.workspace.key); + routeSearch = buildSqlRouteSearch(app.sqlRoute, routeSearch); + win.history.replaceState(null, '', conn.basePath + routeSearch + (loc.hash || '')); + app.renderCurrentSurface(); } }; + const writeRoute = (route: SqlRoute, method: 'push' | 'replace'): void => { + app.sqlRoute = route; + routeSearch = buildSqlRouteSearch(route, routeSearch); + win.history[method === 'push' ? 'pushState' : 'replaceState']( + null, '', conn.basePath + routeSearch + (loc.hash || ''), + ); + }; + app.loadWorkspaceOnBoot = async () => { - const workspaceParams = new URLSearchParams(loc.search); - const explicitKey = workspaceParams.has('ws') ? (workspaceParams.get('ws') ?? '') : null; + const generation = ++routeLoadGeneration; + const explicitKey = app.sqlRoute.workspaceKey; const result = explicitKey !== null ? await app.workspace.loadByKey(explicitKey) : await resolveImplicitOrProvision(); + if (generation !== routeLoadGeneration) return null; if (result.status === 'corrupt') { + app.currentWorkspace = null; + app.workspaceRouteStatus = 'error'; flashToast( 'Saved workspace could not be read. Other local workspaces remain unaffected.', { @@ -1797,103 +1801,91 @@ export function createApp(env: CreateAppEnv = {}): App { ); return null; } - const workspace = result.status === 'ok' ? result.workspace : null; - if (workspace) { - applyCommittedWorkspace(workspace); - await recordOpened(workspace); + if (result.status !== 'ok') { + app.currentWorkspace = null; + app.workspaceRouteStatus = explicitKey !== null ? 'not-found' : 'error'; + const normalized = normalizeSqlRouteSearch(routeSearch); + app.sqlRoute = normalized.route; + if (normalized.search !== routeSearch) { + routeSearch = normalized.search; + win.history.replaceState(null, '', conn.basePath + routeSearch + (loc.hash || '')); + } + return null; + } + const workspace = result.workspace; + await recordOpened(workspace); + if (generation !== routeLoadGeneration) return null; + applyCommittedWorkspace(workspace); + const canonicalRoute = routeForWorkspace(app.sqlRoute, workspace.key); + const canonicalSearch = buildSqlRouteSearch(canonicalRoute, routeSearch); + app.sqlRoute = canonicalRoute; + if (canonicalSearch !== routeSearch) { + routeSearch = canonicalSearch; + win.history.replaceState(null, '', conn.basePath + routeSearch + (loc.hash || '')); } return workspace; }; - // Open the dashboard in a new EDIT-mode tab (#288/#302): the route carries - // the current workspace + dashboard ids (`?ws=&dash=`) so the viewer verifies - // both and shares the primary workspace store with this Workbench tab. We - // stand ready to hand it our credentials — the cross-tab auth-handoff GRANT - // side is the session's job (`conn.grantHandoffTo`, #276 Phase 2); this stays - // app-side only because opening the tab (window.open) is a DOM/browser - // concern. A workspace with no dashboard opens the bare route (legacy). + const renderWorkspaceNotFound = (): void => { + disposeCurrentSurface(); + app.root?.replaceChildren(h('main', { class: 'workspace-not-found' }, + h('h1', null, 'Workspace not found'), + h('p', null, `No local workspace exists for “${app.sqlRoute.workspaceKey ?? ''}”.`), + h('a', { href: conn.basePath || '/sql' }, 'Open the last-used workspace'))); + }; + + app.renderCurrentSurface = () => { + if (app.workspaceRouteStatus !== 'ready' || !app.currentWorkspace) { + renderWorkspaceNotFound(); + return; + } + if (app.sqlRoute.surface === 'dashboard') app.renderDashboard(); + else app.renderApp(); + }; + + app.navigateSqlRoute = async (route, method) => { + const workspaceChanged = route.workspaceKey !== app.sqlRoute.workspaceKey; + writeRoute(route, method); + if (workspaceChanged) { + disposeCurrentSurface(); + const expectedGeneration = routeLoadGeneration + 1; + await app.loadWorkspaceOnBoot(); + if (routeLoadGeneration !== expectedGeneration) return; + } + app.renderCurrentSurface(); + }; + + app.handleSqlPopState = async () => { + const previousKey = app.sqlRoute.workspaceKey; + routeSearch = loc.search; + app.sqlRoute = parseSqlRoute(routeSearch); + if (app.sqlRoute.workspaceKey !== previousKey) disposeCurrentSurface(); + const expectedGeneration = routeLoadGeneration + 1; + await app.loadWorkspaceOnBoot(); + if (routeLoadGeneration !== expectedGeneration) return; + app.renderCurrentSurface(); + }; + app.syncSqlRoute = (search) => { + routeSearch = search; + app.sqlRoute = parseSqlRoute(search); + }; + app.rewriteWorkspaceRoute = (workspaceKey) => { + writeRoute(routeForWorkspace(app.sqlRoute, workspaceKey), 'replace'); + }; + + // Workbench -> Dashboard stays in this tab and creates one useful history + // entry. Dashboard edit is canonical, so `mode=edit` is omitted. function openDashboard(): void { - const dashId = app.state.dashboard?.id; - const wsKey = app.state.workspaceKey; - const search = (wsKey && dashId) - ? buildDashboardSearch({ kind: 'current-workspace', workspaceKey: wsKey, dashboardId: dashId }) - : ''; - const child = app.openWindow(loc.origin + conn.basePath + '/dashboard' + search); - if (child) conn.grantHandoffTo(child); + void app.navigateSqlRoute({ + surface: 'dashboard', workspaceKey: app.state.workspaceKey, mode: 'edit', + }, 'push'); } app.openDashboard = openDashboard; - // #288 Phase 6 — open the current dashboard in a new READ-ONLY VIEW-mode tab - // via the one-time IndexedDB token handoff (ADR-0003). The token is generated - // synchronously so the child tab can be opened in the SAME user-gesture task - // (popup-safe) already pointed at `?st=&dash=`; the validated bundle is - // written to the handoff store in parallel, and the viewer retries the - // one-time `take` briefly to cover the write/read race. The detached workspace - // id minted here is what the consumed handoff materializes under, detached - // from (and unaffected by later edits to) this primary workspace. - function openDashboardForViewing(): void { - const dashboard = app.state.dashboard; - if (!dashboard) { flashToast('No dashboard to view', { document: doc }); return; } - const token = randomHandoffToken(cryptoObj); - const detachedWorkspaceId = uid('wsview-'); - const built = buildViewHandoffRecord(dashboard, app.state.savedQueries, { - detachedWorkspaceId, expiresAt: wallNow() + VIEW_HANDOFF_TTL_MS, - nowISO: new Date(wallNow()).toISOString(), - }); - if (!built.ok) { flashToast('✕ ' + (built.diagnostics[0]?.message || 'Could not prepare dashboard for viewing'), { document: doc }); return; } - const search = buildDashboardSearch({ kind: 'session-bundle', token, dashboardId: dashboard.id }); - const child = app.openWindow(loc.origin + conn.basePath + '/dashboard' + search); - // A blocked popup means nothing will ever consume the token — don't write a - // (potentially multi-MB) orphan record the store never sweeps. Only persist - // the handoff once we know a viewer tab is actually opening. - if (!child) { flashToast('Allow pop-ups to open the dashboard view', { document: doc }); return; } - conn.grantHandoffTo(child); - // Write AFTER opening the child (open must stay in the gesture task); the - // child only reads the token after a full load + auth handoff, well after - // this fast IndexedDB write lands. - app.handoff.put(token, built.record).catch(() => { - flashToast('✕ Could not prepare dashboard for viewing', { document: doc }); - }); - } - app.openDashboardForViewing = openDashboardForViewing; - - // #288 Phase 6 — the VIEW-mode viewer's side of the one-time handoff - // (ADR-0003). Atomically consume this tab's `?st=` token, materialize the - // carried bundle into the persistent detached store under its own id, then - // rewrite the URL to the durable `?ws=&dash=` form (dropping the - // dead token) so a relogin/reload re-opens the detached view rather than a - // spent token. Returns the detached workspace, or null (missing/expired - // token, or an undecodable bundle) → the viewer shows not-found. The opener - // writes the token before opening this tab, and this tab only reaches here - // after a full load + auth handoff, so the record is present by now. - app.consumeDashboardHandoff = async () => { - const src = app.dashboardOpenSource; - if (!src || src.kind !== 'session-bundle') return null; - const record = await app.handoff.take(src.token, wallNow()); - if (!record) return null; - const materialized = materializeDetachedWorkspace(record.text, record.dashboardId, record.detachedWorkspaceId); - if (!materialized.ok) return null; - await app.detachedViews.put({ workspace: materialized.workspace, savedAt: wallNow() }); - const search = buildDashboardSearch({ - kind: 'current-workspace', workspaceKey: materialized.workspace.key, dashboardId: record.dashboardId, - }); - win.history.replaceState(null, '', loc.origin + conn.basePath + '/dashboard' + search); - app.dashboardOpenSource = parseDashboardOpenSource(search); - return materialized.workspace; - }; - - // #302 — after an import committed FROM the standalone Dashboard page, point - // the tab's URL at the (possibly new) current dashboard id and re-render the - // viewer. Import replaces the current dashboard (new id), so the pre-import - // URL's `dash=` would otherwise fail the viewer's strict id verification. app.reloadDashboardRoute = () => { - const dash = app.state.dashboard; - const wsKey = app.state.workspaceKey; - if (dash && wsKey) { - const search = buildDashboardSearch({ kind: 'current-workspace', workspaceKey: wsKey, dashboardId: dash.id }); - win.history.replaceState(null, '', loc.origin + conn.basePath + '/dashboard' + search); - app.dashboardOpenSource = parseDashboardOpenSource(search); - } + app.currentWorkspace = app.currentWorkspace + ? { ...app.currentWorkspace, queries: app.state.savedQueries, dashboard: app.state.dashboard } + : null; app.renderDashboard(); }; @@ -1911,7 +1903,7 @@ export function createApp(env: CreateAppEnv = {}): App { // workspace resolution runs for a username/password session. Without it, // basic auth would keep rendering the placeholder workspace instead of the // requested or last-used persisted workspace. - connect: async (input) => { await conn.connectBasic(input); await app.loadWorkspaceOnBoot(); app.renderApp(); }, + connect: async (input) => { await conn.connectBasic(input); await app.loadWorkspaceOnBoot(); app.renderCurrentSurface(); }, share, copyResult, // `ActionsRegistry.copySnapshot`'s public `result: Json | null` is looser @@ -1942,7 +1934,6 @@ export function createApp(env: CreateAppEnv = {}): App { openCreateInNewTab: (target, name) => openCreateInNewTab(target, name), openShortcuts: () => openShortcuts(app), openDashboard, - openDashboardForViewing, // #302: Dashboard import/export invoked from the Dashboard page's own File // menu (and still from the Workbench during the transition). Export is a // read-only bundle download; import runs the transactional planner and, on @@ -1959,7 +1950,16 @@ export function createApp(env: CreateAppEnv = {}): App { updateSaveBtn: () => app.updateSaveBtn(), }; - app.renderApp = () => renderApp(app, { toggleTheme, startDrag }); + app.renderApp = () => { + disposeFileMenuOverlays(app); + disposeDashboardSurface(); + app.onWorkspaceExternallyChanged = ignoreExternalWorkspaceChange; + disposeWorkbenchMount?.(); + disposeWorkbenchMount = renderApp(app, { toggleTheme, startDrag }); + }; + if (typeof win.addEventListener === 'function') { + win.addEventListener('popstate', () => { void app.handleSqlPopState(); }); + } return app; } @@ -1978,8 +1978,8 @@ export interface RenderAppHelpers { * byte-identically, driven by a narrow `WorkbenchShellDeps` bag instead of * the full `App` — see that module's header comment for what stays coupled * to `app` and why. */ -export function renderApp(app: App, helpers: RenderAppHelpers): void { - mountWorkbenchShell({ +export function renderApp(app: App, helpers: RenderAppHelpers): () => void { + return mountWorkbenchShell({ app, root: app.root, document: app.document, diff --git a/src/ui/app.types.ts b/src/ui/app.types.ts index 8a104635..e3d59775 100644 --- a/src/ui/app.types.ts +++ b/src/ui/app.types.ts @@ -21,11 +21,9 @@ import type { SchemaGraphSession } from '../application/schema-graph-session.js' import type { AppPreferences } from '../application/app-preferences.js'; import type { WorkspaceRepository } from '../workspace/workspace-repository.js'; import type { WorkspaceDiagnostic } from '../dashboard/model/workspace-diagnostics.js'; -import type { HandoffStore } from '../workspace/handoff-store.types.js'; -import type { DetachedViewsStore } from '../workspace/detached-views-store.types.js'; -import type { DashboardOpenSource } from '../dashboard/application/dashboard-open-source.js'; import type { StoredWorkspaceV2 } from '../generated/json-schema.types.js'; import type { SavedQueryV2 } from '../generated/json-schema.types.js'; +import type { SqlRoute } from '../core/sql-route.js'; import type { DynamicSources } from '../core/spec-completion.js'; import type { WorkbenchSession } from './workbench/workbench-session.js'; import type { WorkbenchParameterSession } from '../application/workbench-parameter-session.js'; @@ -206,8 +204,6 @@ export interface ActionsRegistry { openCreateInNewTab(target: string, name?: string): Promise; openShortcuts(): void; openDashboard(): void; - /** #288 Phase 6 — open the current dashboard read-only in a new view tab. */ - openDashboardForViewing(): void; /** #302 — export the current dashboard's dependency closure as a bundle. */ exportDashboard(): void; /** #302 — import a Dashboard bundle through the transactional planner. */ @@ -227,8 +223,8 @@ export interface App { document: Document; /** The auth + config + ClickHouse connection lifecycle (#276 Phase 2) — - * OAuth PKCE login/refresh, Basic probing, IdP config resolution, and - * cross-tab auth handoff, constructible without App/AppState/DOM + * OAuth PKCE login/refresh, Basic probing, and IdP config resolution, + * constructible without App/AppState/DOM * (`src/application/connection-session.ts`). The identity/auth members * below (`isSignedIn`/`email`/`host`/…) are Phase-2 delegates onto this — * shells/bootstrap consume those; a future phase re-points them to @@ -279,8 +275,8 @@ export interface App { // `chAuth`/`basicUserClaim`/`idpId`/`selectIdp`/`chUsername` likewise moved // there in Phase 2; the flat `App` delegates that used to forward onto them // (`isSignedIn`/`email`/`host`/`hostHint`/`basePath`/`setTokens`/ - // `loadConfig`/`loadIdps`/`ensureConfig`/`ensureFreshToken`/`chCtx`/ - // `receiveAuthHandoff`) were deleted in #276 Phase 5 — every consumer reads + // `loadConfig`/`loadIdps`/`ensureConfig`/`ensureFreshToken`/`chCtx`) were + // deleted in #276 Phase 5 — every consumer reads // `app.conn.*` directly now. `showLogin`/`signOut` stay here: they compose // rendering (`renderLoginApp`), not pure forwards. activeTab(): Tab; @@ -313,21 +309,6 @@ export interface App { * favorites-driven Dashboard render still reads legacy keys; Phases 3-6 of * #280 route reads/commits through it and retire the legacy keys. */ workspace: WorkspaceRepository; - /** #288 Phase 6 — the one-time cross-tab token store backing VIEW-mode - * Dashboard handoff (ADR-0003): the opener writes a validated snapshot - * bundle under an unguessable token before opening the viewer tab, which - * atomically consumes (get+delete) and materializes it. Injected IndexedDB - * seam, own database. */ - handoff: HandoffStore; - /** #288 Phase 6 — the persistent detached-views store a consumed handoff - * materializes into: a read-only Dashboard copy under its own fresh - * workspace id, detached from the editable primary workspace so it survives - * relogin/reload and is unaffected by later Workbench edits (ADR-0003). */ - detachedViews: DetachedViewsStore; - /** #288 Phase 6 — THIS tab's parsed `/dashboard` open source: `?ws=&dash=` - * (edit, current-workspace) or `?st=&dash=` (a one-time view handoff), or - * null on a bare/legacy `/dashboard` open. Read by `renderDashboard`. */ - dashboardOpenSource: DashboardOpenSource | null; saveJSON(key: string, value: unknown): void; saveStr(key: string, value: string): void; /** The one deliberate delegate survivor of #276 Phase 5's params-group @@ -442,36 +423,24 @@ export interface App { // Rendering / lifecycle. renderApp(): void; renderDashboard(): void; + renderCurrentSurface(): void; openDashboard(): void; - /** #288 Phase 6 — open the current dashboard in a read-only VIEW-mode tab via - * the one-time IndexedDB token handoff (ADR-0003). */ - openDashboardForViewing(): void; - /** #288/#302 — true when THIS tab is the standalone `/dashboard` route (set - * once from the pathname). Lets shared post-commit logic repaint the right - * surface. */ - dashboardRoute: boolean; - /** #343 step 6 — true while the standalone Dashboard route is showing a - * DETACHED/read-only view (a `?st=` handoff or a `?ws=` id that resolved only - * in the detached-views store). Such a view renders from a detached snapshot, - * not the primary workspace, so `refreshWorkspaceFromStore` must NOT project - * primary-workspace invalidation over it. A Workbench tab and an editable - * Dashboard leave it `false`; `renderDashboard` sets it per render. */ - dashboardReadOnly: boolean; - /** #302 — repaint the standalone Dashboard route after an in-tab import: point - * the URL at the (possibly new) current dashboard id, then re-render. */ + /** Current canonical `/sql` route and the live workspace resolved for it. */ + sqlRoute: SqlRoute; + currentWorkspace: StoredWorkspaceV2 | null; + workspaceRouteStatus: 'ready' | 'not-found' | 'error'; + /** Navigate within the single artifact. Surface changes use push; mode and + * canonicalization use replace. */ + navigateSqlRoute(route: SqlRoute, method: 'push' | 'replace'): Promise; + /** Reparse the browser URL after Back/Forward and mount the selected surface. */ + handleSqlPopState(): Promise; + /** Synchronize route state after bootstrap rewrites an OAuth callback URL. */ + syncSqlRoute(search: string): void; + /** Point the current surface/mode at an already-projected workspace. */ + rewriteWorkspaceRoute(workspaceKey: string): void; + /** Repaint Dashboard after an in-tab import, retaining its route mode. */ reloadDashboardRoute(): void; - /** #288 Phase 6 — the VIEW-mode viewer's side of the one-time handoff: consume - * this tab's `?st=` token, materialize the carried bundle into the persistent - * detached store, rewrite the URL to the durable `?ws=` form, and return the - * detached workspace (or null when the token is missing/expired/undecodable). - * ADR-0003. */ - consumeDashboardHandoff(): Promise; - /** Resolve the keyed or implicit StoredWorkspaceV2 for the Dashboard viewer. - * Returns null when no matching workspace (or requested Dashboard id) is - * available. */ - loadDashboardWorkspace(key?: string, dashboardId?: string): Promise; - /** #287 W4: the async boot-init step — runs `loadDashboardWorkspace` - * (resolve the explicit key or implicit last-opened workspace) and, when it + /** Resolve the explicit or implicit route workspace and, when it * resolves a real aggregate, PROJECTS it onto `state` (`savedQueries`, * `dashboard`, `workspaceId`, `libraryName`) so the whole app (not only * the /dashboard route) treats the aggregate as the saved-query diff --git a/src/ui/dashboard.ts b/src/ui/dashboard.ts index 1d330c9e..0ec864e1 100644 --- a/src/ui/dashboard.ts +++ b/src/ui/dashboard.ts @@ -1,9 +1,8 @@ -// The read-only Dashboard page (#149 / #240 / #280 / #286). Phase 4 of #280 +// The live Dashboard surface (#149 / #240 / #280 / #286 / #407). Phase 4 of #280 // FLIPS Dashboard membership reads off `spec.favorite` and onto // `dashboard.tiles[]`: this module resolves the current `StoredWorkspaceV2` -// (via `app.loadDashboardWorkspace()` — the phase-2 repository, migrating the -// legacy favorites/layout keys once when no aggregate exists), constructs a -// standalone `DashboardViewerSession` over that document + the workspace +// from `app.currentWorkspace`, constructs a `DashboardViewerSession` over that +// document + the workspace // queries, and renders the DOM from the session's `state` signal. The heavy // runtime — presentation resolution, the filter/tile execution waves (with // #235 parallelism), bounded concurrency, per-tile cancellation, and the @@ -76,8 +75,8 @@ import type { GrafanaGridLayoutModel, GridRenderMode } from '../dashboard/layout import { applyCommand } from '../dashboard/application/dashboard-commands.js'; import type { DashboardCommand } from '../dashboard/application/dashboard-commands.js'; import { removeTileMembership } from '../dashboard/application/tile-membership.js'; +import { createEmptyDashboard } from '../dashboard/application/empty-dashboard.js'; import { createQueryResolver } from '../dashboard/application/dashboard-query-resolver.js'; -import { resolveDashboardMode } from '../dashboard/application/session-bundle.js'; import { readDashboardFilterBag, writeDashboardFilterBag, filterBagSignature, } from '../dashboard/model/dashboard-filter-store.js'; @@ -89,8 +88,7 @@ import type { SavedQueryV2, StoredWorkspaceV2, } from '../generated/json-schema.types.js'; import type { App, AppDom, ActionsRegistry } from './app.types.js'; -import type { DashboardOpenSource } from '../dashboard/application/dashboard-open-source.js'; -import type { DetachedViewsStore } from '../workspace/detached-views-store.types.js'; +import type { SqlRoute } from '../core/sql-route.js'; import type { AppState } from '../state.js'; import type { ConnectionSession } from '../application/connection-session.js'; import type { QueryExecutionService } from '../application/query-execution-service.js'; @@ -106,12 +104,10 @@ const Icon: { refresh(): SVGElement; sun(): SVGElement; moon(): SVGElement; - arrow(): SVGElement; trash(): SVGElement; chevDown(): SVGElement; download(): SVGElement; upload(): SVGElement; - eye(): SVGElement; } = IconUntyped; const formatRows: (n: number | null | undefined) => string = formatRowsUntyped; @@ -132,14 +128,10 @@ export interface DashboardApp { wallNow(): number; params: Pick; workspace: Pick; - loadDashboardWorkspace(key?: string, dashboardId?: string): Promise; - // #288 Phase 6 — viewer routing (ADR-0003): the parsed open-source of this - // tab, the detached-views lookup, the one-time-handoff consumer, and the - // projection of the resolved workspace onto app.state (so the File menu's - // export/import act on THIS dashboard). - dashboardOpenSource: DashboardOpenSource | null; - detachedViews: Pick; - consumeDashboardHandoff(): Promise; + currentWorkspace: StoredWorkspaceV2 | null; + sqlRoute: SqlRoute; + navigateSqlRoute(route: SqlRoute, method: 'push' | 'replace'): Promise; + renderDashboard(): void; applyCommittedWorkspace(workspace: StoredWorkspaceV2): void; // #341/#344: every editable Dashboard command commits through // `mutateWorkspace` — the same serialized-queue-plus-read-at-dequeue seam @@ -149,16 +141,13 @@ export interface DashboardApp { // producer's), never a route-local snapshot that another producer's // in-flight commit could make stale. mutateWorkspace: App['mutateWorkspace']; - // #343 step 6: set per render to mark a detached/read-only view (so the - // app-level cross-tab refresh skips projecting the primary workspace over it). - dashboardReadOnly: boolean; // #343 step 6: the route/surface refresh hook — `renderDashboard` overrides it // (per render) so an external workspace change rebuilds this viewer session from - // committed truth. Fires only AFTER the app-level refresh projected a real - // change; a detached read-only route no-ops it. + // committed truth. Fires only AFTER the app-level refresh projected a real change. onWorkspaceExternallyChanged: App['onWorkspaceExternallyChanged']; // #302 — the Dashboard page's own File-menu operations. - actions: Pick; + actions: Pick; + genId(): string; /** #303: persists the isolated per-dashboard filter store (`KEYS.dashFilters`). */ saveJSON(key: string, value: unknown): void; /** #332: the shared cell-detail drawer's own resize persist (`openCellDetail` @@ -199,6 +188,24 @@ let installedGestureCancel: (() => void) | null = null; let installedDashboardChartInteraction: DashboardChartInteractionController | null = null; let installedDashboardCleanup: (() => void) | null = null; +/** Tear down every resource owned by the currently mounted Dashboard surface. */ +export function disposeDashboardSurface(): void { + if (installedGridResizeListener) { + installedGridResizeListener.win.removeEventListener('resize', installedGridResizeListener.handler); + installedGridResizeListener = null; + } + if (installedModifierListeners) { + const m = installedModifierListeners; + m.win.removeEventListener('keydown', m.onKeyDown); + m.win.removeEventListener('keyup', m.onKeyUp); + m.win.removeEventListener('blur', m.onBlur); + installedModifierListeners = null; + } + if (installedGestureCancel) installedGestureCancel(); + installedDashboardCleanup?.(); + installedDashboardCleanup = null; +} + /** * Build the flow preset switcher as a compact `
- clickhouse.example.internal Updated 12:34 - -
@@ -60,6 +41,35 @@ const dashboardFileButton = document.createElement('button'); dashboardFileButton.className = 'hd-file-btn dash-file-btn'; dashboardFileButton.textContent = 'File'; + const tileCount = document.createElement('span'); + tileCount.className = 'dash-chip dash-fav'; + tileCount.textContent = '★ 6 tiles'; + const styleWrap = document.createElement('div'); + styleWrap.className = 'dash-layout-wrap'; + styleWrap.innerHTML = ` + Style + `; + const dashboardTitle = document.createElement('div'); + dashboardTitle.className = 'dash-title'; + dashboardTitle.textContent = 'A deliberately long production operations Dashboard name'; + const updated = document.createElement('span'); + updated.className = 'dash-updated'; + updated.textContent = 'Updated 12:34'; + const refresh = document.createElement('button'); + refresh.className = 'dash-btn dash-refresh'; + refresh.title = 'Re-run all tiles'; + refresh.setAttribute('aria-label', 'Refresh dashboard'); + refresh.innerHTML = ` + + + + Refresh`; const headerApp = { document, dom: {}, @@ -92,7 +102,16 @@ editingLibrary: false, }; document.querySelector('#shared-header').replaceWith( - buildAppHeader(headerApp, { dashboardFileButton }), + buildAppHeader(headerApp, { + dashboardControls: { + tileCount, + fileButton: dashboardFileButton, + style: styleWrap, + title: dashboardTitle, + updated, + refresh, + }, + }), ); const filters = document.querySelector('.dash-filters'); @@ -172,7 +191,6 @@ }; window.__refreshCount = 0; - const refresh = document.querySelector('.dash-refresh'); refresh.addEventListener('click', () => { refresh.disabled = true; window.__refreshCount++; diff --git a/tests/e2e/dashboard-mobile.spec.js b/tests/e2e/dashboard-mobile.spec.js index f9d0044a..20ddf6cd 100644 --- a/tests/e2e/dashboard-mobile.spec.js +++ b/tests/e2e/dashboard-mobile.spec.js @@ -42,7 +42,7 @@ test.describe('Dashboard mobile layout', () => { test('keeps title and actions reachable without viewport overflow at 360px', async ({ page }) => { await openAt(page, 360, 800); - const result = await page.locator('.dash-contextbar').evaluate((header) => { + const result = await page.locator('.dashboard-app-header').evaluate((header) => { const title = header.querySelector('.dash-title').getBoundingClientRect(); const refresh = header.querySelector('.dash-refresh').getBoundingClientRect(); return { @@ -55,6 +55,25 @@ test.describe('Dashboard mobile layout', () => { expect(result).toEqual({ wraps: false, titleBeforeActions: true, actionsInside: true, pageOverflow: 0 }); }); + test('keeps every visible Dashboard header control reachable just above the mobile breakpoint', async ({ page }) => { + await openAt(page, 820, 700); + const result = await page.locator('.dashboard-app-header').evaluate((header) => { + const visible = [...header.children].filter((child) => getComputedStyle(child).display !== 'none'); + const rects = visible.map((child) => child.getBoundingClientRect()); + return { + oneRow: Math.max(...rects.map((rect) => rect.top + rect.height / 2)) + - Math.min(...rects.map((rect) => rect.top + rect.height / 2)) < 2, + inside: rects.every((rect) => rect.left >= 0 && rect.right <= innerWidth), + pageOverflow: document.documentElement.scrollWidth - innerWidth, + outside: visible.flatMap((child, index) => ( + rects[index].left < 0 || rects[index].right > innerWidth + ? [`${child.className}:${rects[index].left}-${rects[index].right}`] : [] + )), + }; + }); + expect(result).toEqual({ oneRow: true, inside: true, pageOverflow: 0, outside: [] }); + }); + test('visually normalizes every saved layout on mobile and restores desktop CSS on resize', async ({ page }) => { await openAt(page, 390); // 'wide'/'full-width' removed (#321) — every remaining flow preset still @@ -262,23 +281,26 @@ test.describe('Dashboard mobile layout', () => { await expect(lastField).toBeInViewport(); }); - test('the layout switcher is a compact select in the header, right after the tile-count chip, matching the workbench panel-picker style (2026-07-18)', async ({ page }) => { + test('the Style picker follows File in the one-row Dashboard header', async ({ page }) => { await openAt(page, 1100, 800); const select = page.locator('.dash-layout-select'); await expect(select).toBeVisible(); await expect(select).toHaveClass(/result-panel-select/); // No more four-button segmented control. await expect(page.locator('.dash-seg-layout')).toHaveCount(0); + await expect(page.locator('.dash-contextbar')).toHaveCount(0); const geometry = await page.evaluate(() => { - const header = document.querySelector('.dash-contextbar'); + const header = document.querySelector('.dashboard-app-header'); const children = [...header.children]; const tileCountIndex = children.findIndex((c) => c.classList.contains('dash-fav')); + const fileIndex = children.findIndex((c) => c.classList.contains('dash-file-btn')); const layoutWrapIndex = children.findIndex((c) => c.classList.contains('dash-layout-wrap')); const layoutWrap = children[layoutWrapIndex]; const tileCount = children[tileCountIndex]; return { - layoutRightAfterTileCount: layoutWrapIndex === tileCountIndex + 1, + tileCountBeforeFile: fileIndex === tileCountIndex + 1, + styleRightAfterFile: layoutWrapIndex === fileIndex + 1, inHeaderNotToolbar: !document.querySelector('.dash-toolbar .dash-layout-wrap'), sameRow: Math.abs( (layoutWrap.getBoundingClientRect().top + layoutWrap.getBoundingClientRect().height / 2) @@ -286,7 +308,10 @@ test.describe('Dashboard mobile layout', () => { ) < 2, }; }); - expect(geometry).toEqual({ layoutRightAfterTileCount: true, inHeaderNotToolbar: true, sameRow: true }); + expect(geometry).toEqual({ + tileCountBeforeFile: true, styleRightAfterFile: true, + inHeaderNotToolbar: true, sameRow: true, + }); await select.selectOption('columns-2'); expect(await select.inputValue()).toBe('columns-2'); diff --git a/tests/helpers/fake-app.ts b/tests/helpers/fake-app.ts index cf278f9f..11607064 100644 --- a/tests/helpers/fake-app.ts +++ b/tests/helpers/fake-app.ts @@ -432,6 +432,9 @@ const appDefaults: App = { sqlRoute: { surface: 'workspace', workspaceKey: null }, currentWorkspace: null, workspaceRouteStatus: 'ready', + captureSurfaceGeneration: () => 0, + isSurfaceGenerationCurrent: (generation) => generation === 0, + refreshCurrentSurfaceAfterStale: (generation) => generation === 0, navigateSqlRoute: async () => {}, handleSqlPopState: async () => {}, syncSqlRoute: () => {}, @@ -645,6 +648,7 @@ export function makeApp>(override }; const base = { state, + sqlRoute: { surface: 'workspace', workspaceKey: state.workspaceKey } as App['sqlRoute'], root, document, Chart: FakeChart, diff --git a/tests/unit/app.test.ts b/tests/unit/app.test.ts index 35b1d0ec..e3006a23 100644 --- a/tests/unit/app.test.ts +++ b/tests/unit/app.test.ts @@ -9,6 +9,7 @@ import type { EditorPort } from '../../src/editor/editor-port.types.js'; import type { CodeViewerOptions } from '../../src/editor/code-viewer.types.js'; import { AST_PROGRESSIVE_THRESHOLD } from '../../src/net/ch-client.js'; import { libraryControls, openFileMenu } from '../../src/ui/file-menu.js'; +import { handleKeydown } from '../../src/ui/shortcuts.js'; import { emptyRecentMap, recordRecent } from '../../src/core/recent-values.js'; import { queryDescription } from '../../src/core/saved-query.js'; import { createSpecValidatorRegistry } from '../../src/core/spec-draft.js'; @@ -980,6 +981,13 @@ describe('renderApp shell', () => { expect(app.dom.userBtn!.getAttribute('title')).toBe('me@example.com'); await Promise.resolve(); }); + it('renders an already-probed ClickHouse version on the Workbench header', () => { + const app = createApp(env()); + app.state.serverVersion = '26.7.1.42'; + app.renderApp(); + expect(qs(app.root, '.conn-status').textContent).toBe('ClickHouse 26.7.1'); + expect(qs(app.root, '.conn-status').getAttribute('title')).toBe('ClickHouse 26.7.1.42'); + }); it('toggles theme via the header button', () => { const { app } = rendered(); app.dom.themeBtn!.dispatchEvent(new Event('click')); // default light → dark @@ -1044,7 +1052,7 @@ describe('loadVersion / loadSchema', () => { const e = env({ fetch: makeFetch([[(u, sql) => /version/.test(sql), resp({ json: { data: [{ v: '26.3.1' }] } })]]) }); const app = createApp(e); app.renderApp(); - await new Promise((r) => setTimeout(r)); + await app.catalog.loadVersion(); expect(app.state.serverVersion).toBe('26.3.1'); expect(app.dom.connStatus!.textContent).toContain('26.3.1'); }); @@ -1052,7 +1060,7 @@ describe('loadVersion / loadSchema', () => { const e = env({ fetch: makeFetch([[(u, sql) => /version/.test(sql), resp({ ok: false, status: 500, text: 'err' })]]) }); const app = createApp(e); app.renderApp(); - await new Promise((r) => setTimeout(r)); + await app.catalog.loadVersion(); expect(app.dom.connStatus!.textContent).toContain('offline'); }); it('records a schema error', async () => { @@ -1352,7 +1360,7 @@ describe('query run', () => { }); it('run() while already running is a no-op (cancel is separate)', async () => { const { app } = appForRun([]); - await new Promise((r) => setTimeout(r)); // let mount-time loadVersion/loadSchema/loadReference settle + await new Promise((r) => setTimeout(r)); // let mount-time loadSchema/loadReference settle app.state.running.value = true; const before = asMock(app.conn.chCtx.fetch).mock.calls.length; await app.actions.run(); @@ -2129,7 +2137,7 @@ describe('query run', () => { [(u, sql) => /version\(\)/.test(sql), resp({ json: { data: [{ v: '26.3.1' }] } })], [(u, sql) => /EXPLAIN/.test(sql), resp({ text: 'plan' })], ]); - await new Promise((r) => setTimeout(r)); // let app.catalog.loadVersion() resolve + await app.catalog.loadVersion(); app.activeTab().sqlDraft = 'SELECT 1'; await app.actions.explainQuery(); expect(sentExplains(e)).toContain('EXPLAIN pretty = 1, compact = 1 SELECT 1'); @@ -2142,7 +2150,7 @@ describe('query run', () => { [(u, sql) => /version\(\)/.test(sql), resp({ json: { data: [{ v: '26.3.1' }] } })], [(u, sql) => /EXPLAIN/.test(sql), resp({ text: 'plan' })], ]); - await new Promise((r) => setTimeout(r)); // let app.catalog.loadVersion() resolve + await app.catalog.loadVersion(); app.activeTab().sqlDraft = 'EXPLAIN SELECT 1'; await app.actions.run(); expect(sentExplains(e)).toContain('EXPLAIN SELECT 1'); // verbatim, no decoration @@ -2919,7 +2927,7 @@ describe('formatQuery', () => { }); it('no-ops on empty SQL', async () => { const { app, e } = appFor([]); - await Promise.resolve(); // let render's loadVersion/loadSchema settle + await Promise.resolve(); // let render's loadSchema settle asMock(e.fetch!).mockClear(); app.activeTab().sqlDraft = ' '; await app.actions.formatQuery(); @@ -2991,7 +2999,7 @@ describe('formatQuery', () => { }); it('optional blocks (#165): a single statement with a block is skipped with a notice — no server call', async () => { const { app, e } = appFor([]); - await Promise.resolve(); // let render's loadVersion/loadSchema settle + await Promise.resolve(); // let render's loadSchema settle asMock(e.fetch!).mockClear(); app.activeTab().sqlDraft = 'select 1 /*[ AND d = {d:String} ]*/'; app.sqlEditor.replaceDocument('select 1 /*[ AND d = {d:String} ]*/'); @@ -3283,8 +3291,10 @@ describe('credentials (basic) sign-in', () => { fetch: makeFetch([[(u, sql) => /SELECT 1/.test(sql), resp({ json: { data: [{ '1': 1 }] } })]]), }); const app = createApp(e); + const loadVersion = vi.spyOn(app.catalog, 'loadVersion').mockResolvedValue(); expect(app.conn.authMode()).toBe('oauth'); await app.actions.connect({ username: 'demo', password: 'demo', host: '' }); + expect(loadVersion).toHaveBeenCalledOnce(); expect(app.conn.authMode()).toBe('basic'); expect(e.sessionStorage!.getItem('ch_basic_auth')).toBe(creds); expect(e.sessionStorage!.getItem('ch_basic_user')).toBe('demo'); @@ -5149,6 +5159,43 @@ describe('mobile best-effort mode (#126)', () => { // ── #407: unified route coordinator ────────────────────────────────────────── describe('unified /sql routing', () => { + const dashboardWorkspace = (queries: SavedQueryV2[] = []): StoredWorkspaceV2 => ({ + storageVersion: 2, + id: 'w', + key: 'w', + name: 'Workspace', + queries, + dashboard: { + documentVersion: 1, + id: 'dash', + title: 'Operations', + revision: 1, + layout: { type: 'flow', version: 1, preset: 'report', items: {} }, + filters: [], + tiles: [], + }, + }); + + const deferNextCommit = (app: App) => { + const realCommit = app.workspace.commit.bind(app.workspace); + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + const commit = vi.spyOn(app.workspace, 'commit').mockImplementation(async (candidate) => { + await gate; + return realCommit(candidate); + }); + return { commit, release }; + }; + + it('tracks mounted renderer lifetime independently from workspace loading', () => { + const app = createApp(env()); + const before = app.captureSurfaceGeneration(); + expect(app.isSurfaceGenerationCurrent(before)).toBe(true); + app.renderApp(); + expect(app.isSurfaceGenerationCurrent(before)).toBe(false); + expect(app.refreshCurrentSurfaceAfterStale(app.captureSurfaceGeneration())).toBe(true); + }); + it('parses dashboard view mode from query parameters', () => { const app = createApp(env({ location: { origin: 'https://ch.example', pathname: '/sql', @@ -5352,6 +5399,31 @@ describe('unified /sql routing', () => { run.click(); expect(saveAction).not.toHaveBeenCalled(); expect(runAction).not.toHaveBeenCalled(); + const shortcut = (over: Record) => ({ + key: '', metaKey: true, ctrlKey: false, shiftKey: false, altKey: false, + preventDefault: vi.fn(), target: {}, ...over, + }); + const shareAction = vi.fn(); + const formatQueryAction = vi.fn(); + const formatSpecAction = vi.fn(); + const setEditorModeAction = vi.fn(); + app.actions.share = shareAction; + app.actions.formatQuery = formatQueryAction; + app.actions.formatSpec = formatSpecAction; + app.actions.setEditorMode = setEditorModeAction; + expect(handleKeydown(shortcut({ key: 'Enter' }), app)).toBeNull(); + expect(handleKeydown(shortcut({ key: 's' }), app)).toBeNull(); + expect(handleKeydown(shortcut({ key: 's', shiftKey: true }), app)).toBeNull(); + expect(handleKeydown(shortcut({ key: 'Enter', shiftKey: true }), app)).toBeNull(); + app.activeTab().editorMode = 'spec'; + expect(handleKeydown(shortcut({ key: 'Enter', shiftKey: true }), app)).toBeNull(); + expect(handleKeydown(shortcut({ key: '1', altKey: true }), app)).toBeNull(); + expect(runAction).not.toHaveBeenCalled(); + expect(saveAction).not.toHaveBeenCalled(); + expect(shareAction).not.toHaveBeenCalled(); + expect(formatQueryAction).not.toHaveBeenCalled(); + expect(formatSpecAction).not.toHaveBeenCalled(); + expect(setEditorModeAction).not.toHaveBeenCalled(); app.renderCurrentSurface(); resolveB({ status: 'ok', workspace: b }); @@ -5388,6 +5460,27 @@ describe('unified /sql routing', () => { await navigation; }); + it('an in-flight Create dashboard completion cannot repaint after switching to Workbench', async () => { + const app = createApp(env()); + const workspace: StoredWorkspaceV2 = { + storageVersion: 2, id: 'w', key: 'w', name: 'W', queries: [], dashboard: null, + }; + await seedActiveWorkspace(app, workspace); + app.sqlRoute = { surface: 'dashboard', workspaceKey: 'w', mode: 'edit' }; + app.renderDashboard(); + const { commit, release } = deferNextCommit(app); + qs(app.root, '.dash-create').click(); + await vi.waitFor(() => expect(commit).toHaveBeenCalledOnce()); + + await app.navigateSqlRoute({ surface: 'workspace', workspaceKey: 'w' }, 'push'); + release(); + await app.flushWorkspaceWrites(); + await vi.waitFor(() => expect(app.currentWorkspace!.dashboard).not.toBeNull()); + + expect(qs(app.root, '.workbench')).not.toBeNull(); + expect(qs(app.root, '.dash-page')).toBeNull(); + }); + it('discards a slower workspace navigation after a newer destination wins', async () => { const app = createApp(env()); app.sqlRoute = { surface: 'workspace', workspaceKey: 'a' }; @@ -5538,6 +5631,144 @@ describe('unified /sql routing', () => { expect(qs(app.root, '.workspace-loading')).toBeNull(); }); + it('a Dashboard edit commit that settles after switching to View refreshes View only', async () => { + const app = createApp(env()); + const workspace = dashboardWorkspace(); + await seedActiveWorkspace(app, workspace); + app.sqlRoute = { surface: 'dashboard', workspaceKey: 'w', mode: 'edit' }; + app.renderDashboard(); + const { commit, release } = deferNextCommit(app); + const style = qs(app.root, '.dash-layout-select'); + style.value = 'columns-2'; + style.dispatchEvent(new Event('change', { bubbles: true })); + await vi.waitFor(() => expect(commit).toHaveBeenCalledOnce()); + + await app.navigateSqlRoute({ + surface: 'dashboard', workspaceKey: 'w', mode: 'view', + }, 'replace'); + expect(qsa(app.root, '.dashboard-mode-switch .editor-mode-btn') + .find((button) => button.textContent === 'View')!.disabled).toBe(true); + release(); + await app.flushWorkspaceWrites(); + await vi.waitFor(() => { + expect((app.currentWorkspace!.dashboard!.layout as { preset?: string }).preset).toBe('columns-2'); + }); + + expect(app.sqlRoute).toEqual({ + surface: 'dashboard', workspaceKey: 'w', mode: 'view', + }); + expect(qs(app.root, '.dash-page')).not.toBeNull(); + expect(qs(app.root, '.workbench')).toBeNull(); + expect(qs(app.root, '.dash-gg-del')).toBeNull(); + }); + + it('a Dashboard edit commit that settles after switching to Workbench cannot replace Workbench', async () => { + const app = createApp(env()); + const workspace = dashboardWorkspace(); + await seedActiveWorkspace(app, workspace); + app.sqlRoute = { surface: 'dashboard', workspaceKey: 'w', mode: 'edit' }; + app.renderDashboard(); + const { commit, release } = deferNextCommit(app); + const style = qs(app.root, '.dash-layout-select'); + style.value = 'columns-3'; + style.dispatchEvent(new Event('change', { bubbles: true })); + await vi.waitFor(() => expect(commit).toHaveBeenCalledOnce()); + + await app.navigateSqlRoute({ surface: 'workspace', workspaceKey: 'w' }, 'push'); + expect(qs(app.root, '.workbench')).not.toBeNull(); + release(); + await app.flushWorkspaceWrites(); + await vi.waitFor(() => { + expect((app.currentWorkspace!.dashboard!.layout as { preset?: string }).preset).toBe('columns-3'); + }); + + expect(app.sqlRoute).toEqual({ surface: 'workspace', workspaceKey: 'w' }); + expect(qs(app.root, '.workbench')).not.toBeNull(); + expect(qs(app.root, '.dash-page')).toBeNull(); + }); + + it('a stale Dashboard rollback callback never replaces the current Workbench DOM', async () => { + const app = createApp(env()); + const workspace = dashboardWorkspace(); + await seedActiveWorkspace(app, workspace); + app.sqlRoute = { surface: 'dashboard', workspaceKey: 'w', mode: 'edit' }; + app.renderDashboard(); + let rejectCommit!: () => void; + const commit = vi.spyOn(app.workspace, 'commit').mockImplementation(() => + new Promise((_resolve, reject) => { + rejectCommit = () => reject(new Error('boom')); + })); + const style = qs(app.root, '.dash-layout-select'); + style.value = 'columns-2'; + style.dispatchEvent(new Event('change', { bubbles: true })); + await vi.waitFor(() => expect(commit).toHaveBeenCalledOnce()); + + await app.navigateSqlRoute({ surface: 'workspace', workspaceKey: 'w' }, 'push'); + rejectCommit(); + await app.flushWorkspaceWrites(); + await Promise.resolve(); + + expect(qs(app.root, '.workbench')).not.toBeNull(); + expect(qs(app.root, '.dash-page')).toBeNull(); + expect(document.querySelector('.share-toast')).toBeNull(); + }); + + it('a linked Workbench Save that settles after switching to Dashboard refreshes Dashboard only', async () => { + const linked = savedQueryFixture({ id: 'q1', name: 'Query 1', sql: 'SELECT 1' }); + const app = createApp(env()); + const workspace = dashboardWorkspace([linked]); + await seedActiveWorkspace(app, workspace); + app.sqlRoute = { surface: 'workspace', workspaceKey: 'w' }; + app.renderApp(); + app.actions.loadIntoNewTab(asQueryOrName(linked)); + app.sqlEditor.replaceDocument('SELECT 2'); + const { commit, release } = deferNextCommit(app); + + const save = app.actions.save(); + await vi.waitFor(() => expect(commit).toHaveBeenCalledOnce()); + await app.navigateSqlRoute({ + surface: 'dashboard', workspaceKey: 'w', mode: 'edit', + }, 'push'); + release(); + await save; + await app.flushWorkspaceWrites(); + await vi.waitFor(() => { + expect(app.currentWorkspace!.queries[0].sql).toBe('SELECT 2'); + }); + + expect(app.sqlRoute).toEqual({ + surface: 'dashboard', workspaceKey: 'w', mode: 'edit', + }); + expect(qs(app.root, '.dash-page')).not.toBeNull(); + expect(qs(app.root, '.workbench')).toBeNull(); + }); + + it('a Workbench Save-as completion cannot settle its removed popover after switching to Dashboard', async () => { + const app = createApp(env()); + const workspace = dashboardWorkspace(); + await seedActiveWorkspace(app, workspace); + app.sqlRoute = { surface: 'workspace', workspaceKey: 'w' }; + app.renderApp(); + app.sqlEditor.replaceDocument('SELECT 42'); + const { commit, release } = deferNextCommit(app); + await app.actions.save(); + qs(document, '.save-popover .sp-input').value = 'Saved later'; + qs(document, '.save-popover .sp-save').click(); + await vi.waitFor(() => expect(commit).toHaveBeenCalledOnce()); + + await app.navigateSqlRoute({ + surface: 'dashboard', workspaceKey: 'w', mode: 'edit', + }, 'push'); + release(); + await app.flushWorkspaceWrites(); + await vi.waitFor(() => expect(app.currentWorkspace!.queries).toHaveLength(1)); + + expect(app.currentWorkspace!.queries[0].sql).toBe('SELECT 42'); + expect(qs(app.root, '.dash-page')).not.toBeNull(); + expect(qs(app.root, '.workbench')).toBeNull(); + expect(document.querySelector('.save-popover')).toBeNull(); + }); + it('a mutation that observes external deletion transitions to not-found', async () => { const app = createApp(env()); app.currentWorkspace = { diff --git a/tests/unit/dashboard.test.ts b/tests/unit/dashboard.test.ts index ac064112..d6352036 100644 --- a/tests/unit/dashboard.test.ts +++ b/tests/unit/dashboard.test.ts @@ -263,6 +263,13 @@ function dashApp(opts: { workspaceRouteStatus: current ? 'ready' : 'not-found', sqlRoute: { surface: 'dashboard', workspaceKey: current?.key ?? 'workspace', mode: 'edit' }, }) as TestApp; + let surfaceGeneration = 0; + app.captureSurfaceGeneration = () => surfaceGeneration; + app.isSurfaceGenerationCurrent = (generation) => generation === surfaceGeneration; + app.renderDashboard = () => { + surfaceGeneration += 1; + void renderDashboard(app as unknown as Parameters[0]); + }; if (current) app.applyCommittedWorkspace(current); if (opts.savedQueries) app.state.savedQueries = opts.savedQueries as AppState['savedQueries']; const loadActive = async (): Promise => { @@ -3159,6 +3166,36 @@ describe('renderDashboard — compound time-range control (#335)', () => { rootEl(app).remove(); }); + it('does not settle a time-range Apply into an obsolete Dashboard renderer', async () => { + const { app } = dashApp({ + workspace: wsWith({ queries: [paired()], tiles: [{ id: 't1', queryId: 'q1' }] }), + }); + let surfaceGeneration = 0; + app.captureSurfaceGeneration = () => surfaceGeneration; + app.isSurfaceGenerationCurrent = (generation) => generation === surfaceGeneration; + await render(app); + document.body.appendChild(rootEl(app)); + const originalExecute = app.exec.executeRead as App['exec']['executeRead']; + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + let delayNext = true; + (app.exec as App['exec']).executeRead = async (result, request) => { + if (delayNext) { delayNext = false; await gate; } + return originalExecute(result, request); + }; + qs(app.root, '.trf-trigger').dispatchEvent(clickEv()); + const inputs = qsa(document.body, '.trf-input'); + inputs[0].value = '-1d'; inputs[0].dispatchEvent(inputEv()); + inputs[1].value = 'now'; inputs[1].dispatchEvent(inputEv()); + qs(document.body, '.trf-btn-primary').dispatchEvent(clickEv()); + await flush(); + surfaceGeneration += 1; + release(); + await flush(); + expect(qs(app.root, '.dash-toolbar > .sr-only').textContent).toBe(''); + rootEl(app).remove(); + }); + it('pushes the OUTGOING committed pair to per-group recents on a changing re-apply, never on the first commit from unset', async () => { const { app } = dashApp({ workspace: wsWith({ queries: [paired()], tiles: [{ id: 't1', queryId: 'q1' }] }), @@ -3946,7 +3983,7 @@ describe('renderDashboard — unified live modes (#407)', () => { ); }); - it('uses the shared compact application header and keeps Dashboard details below it', async () => { + it('puts all Dashboard chrome in the shared compact application header', async () => { const { app } = modeApp({ workspace: wsWith(), mode: 'edit' }); app.state.serverVersion = '26.3.10.4'; await render(app); @@ -3956,9 +3993,17 @@ describe('renderDashboard — unified live modes (#407)', () => { .toEqual(['SQL Browser', 'Dashboard']); expect(qsa(header, '.dashboard-mode-switch .editor-mode-btn').map((button) => button.textContent)) .toEqual(['View', 'Edit']); - expect(qs(header, '.dash-title')).toBeNull(); - expect(qs(header, '.conn-status').textContent).toBe('ClickHouse 26.3.10'); - expect(qs(app.root, '.dash-contextbar .dash-title')).not.toBeNull(); + expect(qs(header, '.dash-title').textContent).toBe('My Dash'); + expect(qs(header, '.dash-fav')).not.toBeNull(); + expect(qs(header, '.dash-file-btn')).not.toBeNull(); + expect(qs(header, '.dash-layout-wrap')).not.toBeNull(); + expect(qs(header, '.dash-updated')).not.toBeNull(); + expect(qs(header, '.dash-refresh')).not.toBeNull(); + expect(qs(header, '.conn-status')).toBeNull(); + expect(qs(header, '[title="View examples"]')).toBeNull(); + expect(qs(header, '[title^="Keyboard shortcuts"]')).toBeNull(); + expect(qs(header, '.user-btn')).toBeNull(); + expect(qs(app.root, '.dash-contextbar')).toBeNull(); }); it('an old Dashboard refresh hook is inert after the route leaves Dashboard', async () => { @@ -3971,6 +4016,17 @@ describe('renderDashboard — unified live modes (#407)', () => { await flush(); expect(app.root!.firstChild).toBe(page); }); + + it('an obsolete Dashboard refresh hook cannot rebuild its replaced renderer', async () => { + const { app } = modeApp({ workspace: wsWith(), mode: 'view' }); + await render(app); + const page = app.root!.firstChild; + const staleHook = app.onWorkspaceExternallyChanged; + app.isSurfaceGenerationCurrent = () => false; + staleHook({ workspace: app.currentWorkspace!, queriesChanged: true }); + await flush(); + expect(app.root!.firstChild).toBe(page); + }); }); describe('renderDashboard — Dashboard header File menu (#302)', () => { @@ -4047,14 +4103,14 @@ describe('renderDashboard — Dashboard header File menu (#302)', () => { expect(labels).not.toContain('Import Dashboard…'); }); - it('live view shows the shared workspace name without exposing Rename', async () => { + it('live view shows the Dashboard name without exposing workspace Rename', async () => { const workspace = wsWith({ id: 'd' }); const { app } = modeApp({ workspace, mode: 'view' }); app.state.libraryName.value = 'Operations'; await render(app); - expect(qs(app.root, '.lib-title').textContent).toBe('Operations'); + expect(qs(app.root, '.dash-title').textContent).toBe('My Dash'); + expect(qs(app.root, '.lib-title')).toBeNull(); expect(qs(app.root, 'button.lib-name')).toBeNull(); - expect(qs(app.root, '.lib-name').getAttribute('aria-label')).toBe('Workspace: Operations'); }); it('an unrelated keydown while the menu is open is ignored', async () => { diff --git a/tests/unit/main.test.ts b/tests/unit/main.test.ts index b60e9236..123a3bf5 100644 --- a/tests/unit/main.test.ts +++ b/tests/unit/main.test.ts @@ -49,6 +49,7 @@ function fakeApp(over: Partial> & { conn?: Partial !!self.token, ...connOver, }, + catalog: { loadVersion: vi.fn(async () => {}) }, renderCurrentSurface: vi.fn(), syncSqlRoute: vi.fn(), showLogin: vi.fn(), @@ -101,12 +102,14 @@ describe('bootstrap', () => { const app = fakeApp(); const out = await bootstrap(app, fakeEnv()); expect(app.showLogin).toHaveBeenCalledWith(null); + expect(app.catalog.loadVersion).not.toHaveBeenCalled(); expect(out.signedIn).toBe(false); }); it('renders the app when already signed in', async () => { const app = fakeApp({ token: valid, conn: { isSignedIn: () => true } }); await bootstrap(app, fakeEnv()); + expect(app.catalog.loadVersion).toHaveBeenCalledOnce(); expect(app.renderCurrentSurface).toHaveBeenCalled(); }); @@ -410,6 +413,7 @@ describe('bootstrap', () => { const app = fakeApp({ token: valid, conn: { isSignedIn: () => true } }); await bootstrap(app, fakeEnv({ location: dashLoc() })); expect(app.loadWorkspaceOnBoot).toHaveBeenCalledOnce(); + expect(app.catalog.loadVersion).toHaveBeenCalledOnce(); expect(app.renderCurrentSurface).toHaveBeenCalledOnce(); }); diff --git a/tests/unit/saved-history.test.ts b/tests/unit/saved-history.test.ts index 7fa4bb03..ce6c32ad 100644 --- a/tests/unit/saved-history.test.ts +++ b/tests/unit/saved-history.test.ts @@ -78,6 +78,37 @@ describe('renderSavedHistory', () => { expect(app.activeTab().editorMode).toBe('sql'); }); + it('stale saved-query mutations finish durably without settling into the obsolete Workbench renderer', async () => { + const app = makeApp(); + app.state.sidePanel.value = 'saved'; + setSaved(app, [ + { id: 'star', name: 'Star', sql: 'SELECT 1', favorite: false }, + { id: 'delete', name: 'Delete', sql: 'SELECT 2', favorite: false }, + { id: 'rename', name: 'Rename', sql: 'SELECT 3', favorite: false }, + ]); + app.refreshCurrentSurfaceAfterStale = vi.fn(() => false); + renderSavedHistory(app); + const rows = qsa(savedList(app), '.saved-row'); + + click(qs(rows[0], '.sv-star')); + await flush(); + click(byTitle(rows[1], 'Delete')); + await flush(); + click(byTitle(rows[2], 'Edit name & description')); + const input = qs(savedList(app), '.sv-edit-name'); + input.value = 'Renamed'; + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + await flush(); + + expect(app.refreshCurrentSurfaceAfterStale).toHaveBeenCalledTimes(3); + expect(queryFavorite(app.state.savedQueries.find((q) => q.id === 'star'))).toBe(true); + expect(app.state.savedQueries.some((q) => q.id === 'delete')).toBe(false); + expect(queryName(app.state.savedQueries.find((q) => q.id === 'rename'))).toBe('Renamed'); + expect(app.state.editingSavedId.value).toBeNull(); + expect(app.updateSaveBtn).not.toHaveBeenCalled(); + expect(app.actions.rerenderTabs).not.toHaveBeenCalled(); + }); + it('saved: an effectful query loads into the editor but does NOT auto-run', () => { const app = makeApp(); app.state.sidePanel.value = 'saved'; diff --git a/tests/unit/shortcuts.test.ts b/tests/unit/shortcuts.test.ts index 0def3fe1..814721c1 100644 --- a/tests/unit/shortcuts.test.ts +++ b/tests/unit/shortcuts.test.ts @@ -85,6 +85,41 @@ describe('handleKeydown', () => { expect(app.actions.run).not.toHaveBeenCalled(); }); + it('fails closed for every Workbench action shortcut while the route is loading', () => { + const app = makeApp({ workspaceRouteStatus: 'loading' }); + const shortcuts = [ + { metaKey: true, key: 'Enter' }, + { metaKey: true, shiftKey: true, key: 'Enter' }, + { metaKey: true, key: 's' }, + { metaKey: true, shiftKey: true, key: 's' }, + { metaKey: true, altKey: true, key: '1' }, + { metaKey: true, altKey: true, key: '2' }, + ]; + for (const shortcut of shortcuts) { + const event = ev(shortcut); + expect(handleKeydown(event, app)).toBeNull(); + expect(event.preventDefault).not.toHaveBeenCalled(); + } + app.activeTab().editorMode = 'spec'; + const specFormat = ev({ metaKey: true, shiftKey: true, key: 'Enter' }); + expect(handleKeydown(specFormat, app)).toBeNull(); + expect(specFormat.preventDefault).not.toHaveBeenCalled(); + expect(app.actions.run).not.toHaveBeenCalled(); + expect(app.actions.save).not.toHaveBeenCalled(); + expect(app.actions.share).not.toHaveBeenCalled(); + expect(app.actions.formatQuery).not.toHaveBeenCalled(); + expect(app.actions.formatSpec).not.toHaveBeenCalled(); + expect(app.actions.setEditorMode).not.toHaveBeenCalled(); + }); + + it('fails closed when a ready Workbench route key does not match the projected workspace', () => { + const app = makeApp({ + sqlRoute: { surface: 'workspace', workspaceKey: 'another-workspace' }, + }); + expect(handleKeydown(ev({ metaKey: true, key: 'Enter' }), app)).toBeNull(); + expect(app.actions.run).not.toHaveBeenCalled(); + }); + it('⌘Enter runs (even when signed out)', () => { const app = makeApp({ conn: { isSignedIn: () => false } }); expect(handleKeydown(ev({ metaKey: true, key: 'Enter' }), app)).toBe('run'); From 29bb7358493b90c9cd1e7ce7044133e4dc524d64 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Thu, 23 Jul 2026 16:57:54 +0000 Subject: [PATCH 4/4] fix: align dashboard header menus and auth startup Co-Authored-By: OpenAI Codex Claude-Session: unavailable (OpenAI Codex) --- CHANGELOG.md | 8 +-- src/main.ts | 2 +- src/styles.css | 26 ++------ src/ui/app-header.ts | 2 +- src/ui/app.ts | 2 +- src/ui/dashboard.ts | 104 +++++++++++++++++------------ tests/e2e/dashboard-mobile.html | 59 +++++++++++----- tests/e2e/dashboard-mobile.spec.js | 30 +++++++-- tests/unit/app.test.ts | 18 ++--- tests/unit/dashboard.test.ts | 60 +++++++++++------ tests/unit/main.test.ts | 30 +++++++++ 11 files changed, 220 insertions(+), 121 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 793348d0..b14863c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,10 +18,10 @@ auto-generated per-PR notes; this file is the curated, human-readable history. document without authoring controls; an explicit missing workspace never falls back, and an empty workspace offers Create only in edit mode. The old surface becomes an inert loading route before cross-workspace navigation, - while same-workspace Back/Forward switches immediately. Dashboard uses one - compact header row for its surface, tile count, File, Style, name, View/Edit, - update, refresh, and theme controls. Global Workbench shortcuts fail closed - outside a ready matching route, and renderer generations let in-flight + while same-workspace Back/Forward switches immediately. Dashboard uses the + shared header row for its surface, tile count, File, active-style menu, name, + View/Edit, update, Refresh, and theme controls. Global Workbench shortcuts + fail closed outside a ready matching route, and renderer generations let in-flight durable writes finish without obsolete Dashboard or Workbench callbacks repainting the selected surface. Direct Dashboard startup now shares the authenticated server-version probe. Empty Dashboard routes also react when diff --git a/src/main.ts b/src/main.ts index 09412a85..f50570b1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -185,9 +185,9 @@ export async function bootstrap(app: BootstrapApp, env: BootstrapEnv): Promise<{ // ch_auth=basic username, not the raw email claim) on first paint. // (ensureConfig is a no-op in basic mode.) await app.conn.ensureConfig(); - void app.catalog.loadVersion(); await app.loadWorkspaceOnBoot(); app.renderCurrentSurface(); + void app.catalog.loadVersion(); } else { app.showLogin(callbackError); } diff --git a/src/styles.css b/src/styles.css index 1866a1f3..4c533a8b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2364,10 +2364,6 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } all decorative or desktop-only; File / theme / user menu remain. */ @media (max-width: 900px) { .logo-name, .env-chip, .app-header .hd-divider, .conn-status, .hd-hide-mobile { display: none; } - .dashboard-app-header { gap: 6px; padding: 0 8px; } - .dashboard-app-header .dash-layout-label, - .dashboard-app-header .dash-updated, - .dashboard-app-header .dash-refresh-label { display: none; } /* #302: the header Dashboard control keeps its icon but drops its label on narrow screens (its accessible name stays on the button). */ .hd-hide-mobile-label { display: none; } @@ -2515,15 +2511,9 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } padding: 8px 20px; background: var(--bg-header); border-bottom: 1px solid var(--border); } .dash-toolbar:not(.has-filters) { display: none; } -/* The flow preset switcher (2026-07-18: a `.result-panel-select` dropdown in - the header's top row, replacing the old `.dash-seg` segmented button group - that lived in the filter toolbar — see dashboard.ts). */ -.dash-layout-wrap { display: inline-flex; align-items: center; gap: 2px; flex: 0 0 auto; } -.dash-layout-label { - height: 26px; padding-left: 8px; display: inline-flex; align-items: center; - color: var(--fg-mute); font-size: 12.5px; font-weight: 500; -} -.dashboard-app-header .dash-layout-select { padding-left: 4px; } +/* The File-style layout menu in the header's top row, replacing the old + `.dash-seg` segmented button group that lived in the filter toolbar. */ +.dash-layout-wrap { display: inline-flex; align-items: center; flex: 0 0 auto; } /* Global filter bar (#149 D3) — one field per detected {name:Type}, reusing the workbench's var-field/var-name/var-input classes. #294: the field region IS its own horizontally-scrolling viewport @@ -2558,7 +2548,7 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } .dash-back:hover { background: var(--bg-hover); color: var(--fg); } .dash-back svg { transform: scaleX(-1); } .dash-title { - flex: 1 1 auto; min-width: 36px; overflow: hidden; + flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; font-weight: 500; color: var(--fg); } @@ -2573,7 +2563,8 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } flex-shrink: 0; white-space: nowrap; font-size: 11px; color: var(--fg-faint); font-family: var(--mono); } -.dash-refresh { flex-shrink: 0; white-space: nowrap; } +.dash-refresh-wrap { flex-shrink: 0; } +.dash-refresh { width: 42px; padding: 0; white-space: nowrap; } .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); @@ -2799,15 +2790,12 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } .dash-src, .dash-updated, .dash-spacer, - .dash-refresh-label, .dash-layout-wrap { display: none; } .dash-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .dash-icobtn, - .dash-refresh { width: 30px; height: 30px; flex-shrink: 0; } - .dash-refresh { padding: 0; justify-content: center; } + .dash-icobtn { width: 30px; height: 30px; flex-shrink: 0; } .dash-toolbar { padding: 6px 10px; } diff --git a/src/ui/app-header.ts b/src/ui/app-header.ts index 6d07bfaf..d0c93bb1 100644 --- a/src/ui/app-header.ts +++ b/src/ui/app-header.ts @@ -14,7 +14,7 @@ export interface AppHeaderOptions { style: HTMLElement | null; title: HTMLElement; updated: HTMLElement; - refresh: HTMLButtonElement; + refresh: HTMLElement; }; /** Missing-dashboard routes have no viewer session, but still own the * Dashboard-scoped File menu. */ diff --git a/src/ui/app.ts b/src/ui/app.ts index ed569bc5..c9ae64a2 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -1991,9 +1991,9 @@ export function createApp(env: CreateAppEnv = {}): App { // requested or last-used persisted workspace. connect: async (input) => { await conn.connectBasic(input); - void app.catalog.loadVersion(); await app.loadWorkspaceOnBoot(); app.renderCurrentSurface(); + void app.catalog.loadVersion(); }, share, copyResult, diff --git a/src/ui/dashboard.ts b/src/ui/dashboard.ts index 8f244eac..5b9605e9 100644 --- a/src/ui/dashboard.ts +++ b/src/ui/dashboard.ts @@ -210,28 +210,45 @@ export function disposeDashboardSurface(): void { installedDashboardCleanup = null; } -/** - * Build the flow preset switcher as a compact ` - - - - - - `; + const styleButton = document.createElement('button'); + styleButton.className = 'hd-file-btn dash-style-btn'; + styleButton.setAttribute('aria-haspopup', 'menu'); + styleButton.setAttribute('aria-expanded', 'false'); + styleButton.setAttribute('aria-label', 'Dashboard style: Report'); + styleButton.innerHTML = 'Report'; + styleButton.value = 'report'; + let styleHandle = null; + const styles = [ + ['grafana-grid', 'Grid Tiles'], ['full', 'Full view'], ['report', 'Report'], + ['columns-2', '2 columns'], ['columns-3', '3 columns'], + ]; + styleButton.onclick = () => { + if (styleHandle) { + styleHandle.close(); + styleButton.focus(); + return; + } + styleHandle = openMenu({ + document, + trigger: styleButton, + menuClass: 'dash-file-menu dash-style-menu', + rows: styles.map(([value, label]) => ({ + kind: 'item', + label, + meta: value === styleButton.value ? 'Current' : null, + onClick: () => { + styleButton.value = value; + styleButton.querySelector('span').textContent = label; + styleButton.setAttribute('aria-label', `Dashboard style: ${label}`); + }, + })), + onClose: () => { styleHandle = null; }, + }); + }; + styleWrap.append(styleButton); const dashboardTitle = document.createElement('div'); dashboardTitle.className = 'dash-title'; dashboardTitle.textContent = 'A deliberately long production operations Dashboard name'; @@ -62,14 +90,13 @@ updated.className = 'dash-updated'; updated.textContent = 'Updated 12:34'; const refresh = document.createElement('button'); - refresh.className = 'dash-btn dash-refresh'; + refresh.className = 'editor-mode-btn dash-refresh'; refresh.title = 'Re-run all tiles'; refresh.setAttribute('aria-label', 'Refresh dashboard'); - refresh.innerHTML = ` - - - - Refresh`; + refresh.textContent = 'Refresh'; + const refreshControl = document.createElement('div'); + refreshControl.className = 'editor-mode-switch dash-refresh-wrap'; + refreshControl.append(refresh); const headerApp = { document, dom: {}, @@ -109,7 +136,7 @@ style: styleWrap, title: dashboardTitle, updated, - refresh, + refresh: refreshControl, }, }), ); diff --git a/tests/e2e/dashboard-mobile.spec.js b/tests/e2e/dashboard-mobile.spec.js index 20ddf6cd..f9a11210 100644 --- a/tests/e2e/dashboard-mobile.spec.js +++ b/tests/e2e/dashboard-mobile.spec.js @@ -15,7 +15,7 @@ test.describe('Dashboard mobile layout', () => { await expect(page.getByRole('group', { name: 'Dashboard mode' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Toggle theme' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Refresh dashboard' })).toBeVisible(); - await expect(page.locator('.dash-refresh-label')).toBeHidden(); + await expect(page.locator('.dash-refresh-label')).toHaveCount(0); for (const selector of ['.dash-fav', '.dash-updated', '.dash-layout-wrap']) { await expect(page.locator(selector)).toBeHidden(); } @@ -281,14 +281,23 @@ test.describe('Dashboard mobile layout', () => { await expect(lastField).toBeInViewport(); }); - test('the Style picker follows File in the one-row Dashboard header', async ({ page }) => { + test('the File-style layout picker follows File in the one-row Dashboard header', async ({ page }) => { await openAt(page, 1100, 800); - const select = page.locator('.dash-layout-select'); - await expect(select).toBeVisible(); - await expect(select).toHaveClass(/result-panel-select/); + const style = page.locator('.dash-style-btn'); + await expect(style).toBeVisible(); + await expect(style).toHaveClass(/hd-file-btn/); + await expect(style).toHaveText(/Report/); + await expect(page.locator('.dashboard-app-header')).not.toContainText('Style'); // No more four-button segmented control. await expect(page.locator('.dash-seg-layout')).toHaveCount(0); await expect(page.locator('.dash-contextbar')).toHaveCount(0); + const controlSizes = await page.evaluate(() => { + const edit = [...document.querySelectorAll('.dashboard-mode-switch .editor-mode-btn')] + .find((button) => button.textContent === 'Edit').getBoundingClientRect(); + const refresh = document.querySelector('.dash-refresh').getBoundingClientRect(); + return { edit: [edit.width, edit.height], refresh: [refresh.width, refresh.height] }; + }); + expect(controlSizes.refresh).toEqual(controlSizes.edit); const geometry = await page.evaluate(() => { const header = document.querySelector('.dashboard-app-header'); @@ -313,7 +322,14 @@ test.describe('Dashboard mobile layout', () => { inHeaderNotToolbar: true, sameRow: true, }); - await select.selectOption('columns-2'); - expect(await select.inputValue()).toBe('columns-2'); + await style.click(); + const menu = page.locator('.dash-style-menu'); + await expect(menu).toHaveClass(/file-menu/); + await expect(menu.locator('.fm-label')).toHaveText( + ['Grid Tiles', 'Full view', 'Report', '2 columns', '3 columns'], + ); + await menu.getByRole('menuitem', { name: '2 columns' }).click(); + await expect(style).toHaveText(/2 columns/); + expect(await style.evaluate((button) => button.value)).toBe('columns-2'); }); }); diff --git a/tests/unit/app.test.ts b/tests/unit/app.test.ts index e3006a23..2b508160 100644 --- a/tests/unit/app.test.ts +++ b/tests/unit/app.test.ts @@ -5187,6 +5187,12 @@ describe('unified /sql routing', () => { return { commit, release }; }; + const pickDashboardStyle = (app: App, label: string): void => { + qs(app.root, '.dash-style-btn').click(); + qsa(document.body, '.dash-style-menu .fm-item') + .find((item) => item.querySelector('.fm-label')?.textContent === label)!.click(); + }; + it('tracks mounted renderer lifetime independently from workspace loading', () => { const app = createApp(env()); const before = app.captureSurfaceGeneration(); @@ -5638,9 +5644,7 @@ describe('unified /sql routing', () => { app.sqlRoute = { surface: 'dashboard', workspaceKey: 'w', mode: 'edit' }; app.renderDashboard(); const { commit, release } = deferNextCommit(app); - const style = qs(app.root, '.dash-layout-select'); - style.value = 'columns-2'; - style.dispatchEvent(new Event('change', { bubbles: true })); + pickDashboardStyle(app, '2 columns'); await vi.waitFor(() => expect(commit).toHaveBeenCalledOnce()); await app.navigateSqlRoute({ @@ -5669,9 +5673,7 @@ describe('unified /sql routing', () => { app.sqlRoute = { surface: 'dashboard', workspaceKey: 'w', mode: 'edit' }; app.renderDashboard(); const { commit, release } = deferNextCommit(app); - const style = qs(app.root, '.dash-layout-select'); - style.value = 'columns-3'; - style.dispatchEvent(new Event('change', { bubbles: true })); + pickDashboardStyle(app, '3 columns'); await vi.waitFor(() => expect(commit).toHaveBeenCalledOnce()); await app.navigateSqlRoute({ surface: 'workspace', workspaceKey: 'w' }, 'push'); @@ -5698,9 +5700,7 @@ describe('unified /sql routing', () => { new Promise((_resolve, reject) => { rejectCommit = () => reject(new Error('boom')); })); - const style = qs(app.root, '.dash-layout-select'); - style.value = 'columns-2'; - style.dispatchEvent(new Event('change', { bubbles: true })); + pickDashboardStyle(app, '2 columns'); await vi.waitFor(() => expect(commit).toHaveBeenCalledOnce()); await app.navigateSqlRoute({ surface: 'workspace', workspaceKey: 'w' }, 'push'); diff --git a/tests/unit/dashboard.test.ts b/tests/unit/dashboard.test.ts index d6352036..9559dfff 100644 --- a/tests/unit/dashboard.test.ts +++ b/tests/unit/dashboard.test.ts @@ -287,13 +287,25 @@ const render = (app: TestApp): Promise => renderDashboard(app as unknown a // observe `commit` having been called; a macrotask flush lets every pending // microtask (the queue + the commit promise + its projection callback) run. const flush = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); -/** The flow preset switcher (2026-07-18: a `