From 238f00cdd23eb7da58f6406f0e2f7758497a0222 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Sat, 18 Jul 2026 16:42:34 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(#288,#302):=20Dashboard=20v1=20phase?= =?UTF-8?q?=206=20=E2=80=94=20direct=20+=20full-screen=20viewing=20+=20Fil?= =?UTF-8?q?e-menu=20restructure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WIP checkpoint (gate green: 3559 tests, per-file coverage, build OK). - DashboardOpenSource contract + ?ws/?dash/?st route parsing (never ?state) - one-time IndexedDB token handoff store + persistent detached-views store - session-bundle glue (build/materialize/mode-resolve), viewer edit/view/not-found - Dashboard header File menu (import/export/open-for-viewing); workbench header Dashboard→ nav; Dashboard section removed from the Workbench File menu (#302) - ADR-0003; boundary test extended to src/workspace Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GLMprUnwnz4oaz3cnLRKcz --- docs/ADR-0003-dashboard-viewing.md | 133 ++++++++++ src/core/handoff-token.ts | 26 ++ .../application/dashboard-open-source.ts | 57 +++++ src/dashboard/application/session-bundle.ts | 121 +++++++++ src/styles.css | 19 ++ src/ui/app.ts | 124 +++++++++- src/ui/app.types.ts | 43 ++++ src/ui/dashboard.ts | 157 ++++++++++-- src/ui/file-menu.ts | 82 ++++--- src/ui/workbench/workbench-shell.ts | 6 +- src/workspace/detached-views-store.types.ts | 25 ++ src/workspace/handoff-store.types.ts | 33 +++ .../indexeddb-detached-views-store.ts | 135 ++++++++++ src/workspace/indexeddb-handoff-store.ts | 117 +++++++++ tests/helpers/fake-app.ts | 18 ++ tests/unit/app.test.ts | 130 ++++++++++ tests/unit/dashboard-boundaries.test.js | 9 + tests/unit/dashboard-open-source.test.ts | 101 ++++++++ tests/unit/dashboard.test.ts | 156 ++++++++++++ tests/unit/file-menu.test.ts | 150 ++++++----- tests/unit/handoff-token.test.ts | 56 +++++ .../indexeddb-detached-views-store.test.ts | 232 ++++++++++++++++++ tests/unit/indexeddb-handoff-store.test.ts | 212 ++++++++++++++++ tests/unit/session-bundle.test.ts | 163 ++++++++++++ 24 files changed, 2200 insertions(+), 105 deletions(-) create mode 100644 docs/ADR-0003-dashboard-viewing.md create mode 100644 src/core/handoff-token.ts create mode 100644 src/dashboard/application/dashboard-open-source.ts create mode 100644 src/dashboard/application/session-bundle.ts create mode 100644 src/workspace/detached-views-store.types.ts create mode 100644 src/workspace/handoff-store.types.ts create mode 100644 src/workspace/indexeddb-detached-views-store.ts create mode 100644 src/workspace/indexeddb-handoff-store.ts create mode 100644 tests/unit/dashboard-open-source.test.ts create mode 100644 tests/unit/handoff-token.test.ts create mode 100644 tests/unit/indexeddb-detached-views-store.test.ts create mode 100644 tests/unit/indexeddb-handoff-store.test.ts create mode 100644 tests/unit/session-bundle.test.ts diff --git a/docs/ADR-0003-dashboard-viewing.md b/docs/ADR-0003-dashboard-viewing.md new file mode 100644 index 00000000..93ae78ae --- /dev/null +++ b/docs/ADR-0003-dashboard-viewing.md @@ -0,0 +1,133 @@ +# ADR-0003: Dashboard viewing — edit vs view modes, and the one-time cross-tab state handoff + +- **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). + +## 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. + +## Decision + +### Two explicit viewing modes + +| | **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 store (`asb-workspace`) | 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. + +### The `DashboardOpenSource` contract + +```ts +type DashboardOpenSource = + | { kind: 'current-workspace'; workspaceId: string; dashboardId: string } + | { kind: 'session-bundle'; token: string; dashboardId: string }; +``` + +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` id resolves in**, not by a +spoofable URL flag: the id 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`, 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 `asb-workspace`: + `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. + +## 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). diff --git a/src/core/handoff-token.ts b/src/core/handoff-token.ts new file mode 100644 index 00000000..41559ced --- /dev/null +++ b/src/core/handoff-token.ts @@ -0,0 +1,26 @@ +// 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/dashboard/application/dashboard-open-source.ts b/src/dashboard/application/dashboard-open-source.ts new file mode 100644 index 00000000..459bf12a --- /dev/null +++ b/src/dashboard/application/dashboard-open-source.ts @@ -0,0 +1,57 @@ +// 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'; workspaceId: 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 workspaceId = params.get('ws'); + if (workspaceId) return { kind: 'current-workspace', workspaceId, 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.workspaceId); + } + params.set('dash', source.dashboardId); + return '?' + params.toString(); +} diff --git a/src/dashboard/application/session-bundle.ts b/src/dashboard/application/session-bundle.ts new file mode 100644 index 00000000..d691d957 --- /dev/null +++ b/src/dashboard/application/session-bundle.ts @@ -0,0 +1,121 @@ +// 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, StoredWorkspaceV1, +} from '../../generated/json-schema.types.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: StoredWorkspaceV1 } + | { ok: false; diagnostics: WorkspaceDiagnostic[] }; + +/** + * Materialize a consumed handoff record's bundle text into a detached, + * read-only `StoredWorkspaceV1`. 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: 1, + id: 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: StoredWorkspaceV1 } + | { mode: 'view'; workspace: StoredWorkspaceV1 } + | { mode: 'not-found' }; + +const matches = ( + workspace: StoredWorkspaceV1 | null, source: Extract, +): workspace is StoredWorkspaceV1 => + !!workspace && workspace.id === source.workspaceId && 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-workspace` store) and `detached` (from the `asb-dashboard-views` + * detached-views store). Both the workspace id AND the Dashboard id must + * match — a workspace id 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: StoredWorkspaceV1 | null, + detached: StoredWorkspaceV1 | 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/styles.css b/src/styles.css index 4d3742e3..6c5e7904 100644 --- a/src/styles.css +++ b/src/styles.css @@ -344,6 +344,16 @@ body { cursor: pointer; font-family: inherit; font-size: 12.5px; font-weight: 500; flex-shrink: 0; } .hd-file-btn:hover { background: var(--bg-hover); color: var(--fg); } +/* #302: header Dashboard nav control, placed right after the workspace name. + A plain navigation button (not a dropdown); collapses to icon-only below the + mobile breakpoint via .hd-hide-mobile-label. */ +.hd-dash-nav { + display: flex; align-items: center; gap: 5px; height: 26px; padding: 0 8px; + border: none; border-radius: 6px; background: transparent; color: var(--fg-mute); + cursor: pointer; font-family: inherit; font-size: 12.5px; font-weight: 500; flex-shrink: 0; +} +.hd-dash-nav:hover { background: var(--bg-hover); color: var(--fg); } +.hd-dash-nav svg { width: 14px; height: 14px; } .lib-title { display: flex; align-items: center; min-width: 0; } .lib-name { display: flex; align-items: center; gap: 6px; height: 24px; padding: 0 7px; @@ -2036,6 +2046,9 @@ 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; } + /* #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; } /* Cap the user-menu label so a long local-part (`user ▾`) can't widen the header back past the viewport. */ .hd-btn.user-btn .user-short { max-width: 90px; } @@ -2230,6 +2243,12 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } } .dash-btn:hover { background: var(--bg-hover); } .dash-btn:disabled { opacity: .55; cursor: default; } +/* #302: the Dashboard header's own File menu reuses the shared .file-menu / + .fm-item dropdown chrome; only its width is tuned here. */ +.dash-file-menu { width: 220px; } +/* #288: the read-only viewer's not-found state (a stale/replaced dashboard link + or a spent one-time preview token). */ +.dash-notfound-title { margin: 0 0 8px; font-size: 16px; color: var(--fg); } /* Arrange grid (#286 flow@1): the container is a vertical STACK of `.dash-row` wrappers — each row is itself the grid that owns its column count (the active preset's columns, or 1 on mobile), set inline by the flow renderer. The diff --git a/src/ui/app.ts b/src/ui/app.ts index 92f68b17..ea71ac78 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -74,7 +74,14 @@ import { createSchemaGraphSession, SchemaGraphAuthRequiredError } from '../appli import { createAppPreferences } from '../application/app-preferences.js'; import { createWorkspaceRepository } 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 { migrateLegacyWorkspaceIfNeeded } from '../workspace/legacy-migration.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 } 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'; @@ -113,6 +120,12 @@ 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; @@ -202,6 +215,23 @@ 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 }); + // #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); // 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 @@ -1488,16 +1518,93 @@ export function createApp(env: CreateAppEnv = {}): App { return workspace; }; - // Open the dashboard in a new tab and stand ready to hand it our credentials - // — the cross-tab auth-handoff GRANT side itself is the session's job now - // (`conn.grantHandoffTo`, #276 Phase 2); this stays app-side only because - // opening the tab (window.open) is a DOM/browser concern. + // 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). function openDashboard(): void { - const child = app.openWindow(loc.origin + conn.basePath + '/dashboard'); + const dashId = app.state.dashboard?.id; + const wsId = app.state.workspaceId; + const search = (wsId && dashId) + ? buildDashboardSearch({ kind: 'current-workspace', workspaceId: wsId, dashboardId: dashId }) + : ''; + const child = app.openWindow(loc.origin + conn.basePath + '/dashboard' + search); if (child) conn.grantHandoffTo(child); } 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); + if (child) conn.grantHandoffTo(child); + // Write AFTER opening the child (open must stay in the gesture task); the + // viewer's bounded retry covers the case where it reads before this 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', workspaceId: materialized.workspace.id, 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 wsId = app.state.workspaceId; + if (dash && wsId) { + const search = buildDashboardSearch({ kind: 'current-workspace', workspaceId: wsId, dashboardId: dash.id }); + win.history.replaceState(null, '', loc.origin + conn.basePath + '/dashboard' + search); + app.dashboardOpenSource = parseDashboardOpenSource(search); + } + app.renderDashboard(); + }; + // --- actions registry -------------------------------------------------- app.actions = { run: (opts) => workbench.runEntry(opts), @@ -1545,6 +1652,13 @@ 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 + // the standalone Dashboard route, re-renders the dashboard on success. + exportDashboard: () => exportDashboardAction(app), + importDashboard: () => triggerImportDashboard(app), // Editor-mutating actions jump the mobile bottom-nav to the Editor panel // (#126) so a schema tap / SHOW CREATE lands where the user can see it. insertAtCursor: (text) => { app.sqlEditor.insertAtCursor(text); toEditorOnMobile(); }, diff --git a/src/ui/app.types.ts b/src/ui/app.types.ts index 61f489f5..7cbd2ffc 100644 --- a/src/ui/app.types.ts +++ b/src/ui/app.types.ts @@ -19,6 +19,9 @@ import type { SchemaCatalogService } from '../application/schema-catalog-service 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 { 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 { StoredWorkspaceV1 } from '../generated/json-schema.types.js'; import type { SavedQueryV2 } from '../generated/json-schema.types.js'; import type { DynamicSources } from '../core/spec-completion.js'; @@ -64,6 +67,9 @@ export interface AppDom { fileMenu?: HTMLElement; fileMenuOverlay?: HTMLElement; libraryTitle?: HTMLElement; + /** #302 — the Workbench header "Dashboard →" nav control (shown only when the + * current workspace has a Dashboard). */ + dashboardNav?: HTMLElement; qtabsInner?: HTMLElement; resultsRegion?: HTMLElement; runElapsedEl?: HTMLElement; @@ -159,6 +165,12 @@ 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. */ + importDashboard(): void; insertAtCursor(text: string): void; replaceEditor(text: string): void; loadColumns(db: string, table: string): Promise; @@ -247,6 +259,21 @@ 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 @@ -362,6 +389,22 @@ export interface App { renderApp(): void; renderDashboard(): 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; + /** #302 — repaint the standalone Dashboard route after an in-tab import: point + * the URL at the (possibly new) current dashboard id, then re-render. */ + 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; /** #286 Phase 4: resolve the current StoredWorkspaceV1 for the Dashboard * viewer, running the one-shot legacy migration first when no aggregate * exists. Returns null when neither an aggregate nor a migratable legacy diff --git a/src/ui/dashboard.ts b/src/ui/dashboard.ts index 7243d509..3cc24727 100644 --- a/src/ui/dashboard.ts +++ b/src/ui/dashboard.ts @@ -29,7 +29,7 @@ // is injected on the `app` controller. import { effect } from '@preact/signals-core'; -import { h } from './dom.js'; +import { h, fixedAnchor } from './dom.js'; import { Icon as IconUntyped } from './icons.js'; import { renderResolvedPanel } from './panels.js'; import { resolvePanel } from '../core/panel-cfg.js'; @@ -52,6 +52,7 @@ import { defaultLayoutRegistry } from '../dashboard/layouts/layout-registry.js'; import { flowLayoutPlugin } from '../dashboard/layouts/flow-layout.js'; import { applyCommand } from '../dashboard/application/dashboard-commands.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'; @@ -61,7 +62,9 @@ import { KEYS } from '../state.js'; import type { DashboardDocumentV1, DashboardFilterDefinitionV1, FlowPresetV1, SavedQueryV2, StoredWorkspaceV1, } from '../generated/json-schema.types.js'; -import type { App, AppDom } from './app.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 { AppState } from '../state.js'; import type { ConnectionSession } from '../application/connection-session.js'; import type { QueryExecutionService } from '../application/query-execution-service.js'; @@ -97,6 +100,16 @@ export interface DashboardApp { params: Pick; workspace: Pick; loadDashboardWorkspace(): 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; + applyCommittedWorkspace(workspace: StoredWorkspaceV1): void; + // #302 — the Dashboard page's own File-menu operations. + actions: Pick; /** #303: persists the isolated per-dashboard filter store (`KEYS.dashFilters`). */ saveJSON(key: string, value: unknown): void; } @@ -175,6 +188,85 @@ function synthesizeImplicitFilters( return out; } +/** #288 — the read-only viewer's not-found state: a `current-workspace` route + * whose workspace/dashboard ids no longer resolve (the workspace was replaced, + * or the detached view was evicted), or a spent/expired one-time handoff token. + * Renders a message + a link back and executes nothing. */ +function renderDashboardNotFound(app: DashboardApp): void { + const back = h('a', { class: 'dash-back', href: app.conn.basePath || '/sql' }, + Icon.arrow(), h('span', { class: 'dash-back-label' }, 'SQL Browser')); + // `!`: the dashboard renders only into a mounted page. + app.root!.replaceChildren(h('div', { class: 'dash-page dash-notfound' }, + h('div', { class: 'dash-empty' }, + h('h2', { class: 'dash-notfound-title' }, 'Dashboard unavailable'), + h('p', null, + 'This dashboard link is no longer available — the workspace may have been ' + + 'replaced, or a one-time preview link was already used or has expired.'), + back))); +} + +/** #302 — the standalone Dashboard header's own "File" menu: a keyboard- and + * screen-reader-accessible dropdown owning Dashboard-scoped operations. Edit + * mode offers Export / Import / Open for viewing; a read-only (detached) view + * offers Export only (import + re-preview are edit-context operations). Every + * item delegates to an `app.actions.*` seam (dashboard.ts never reaches into + * app.ts). Esc/outside-click close and restore focus; arrows move between + * items. */ +function buildDashboardFileMenu(app: DashboardApp, readOnly: boolean): HTMLElement { + const doc = app.document; + const btn = h('button', { + class: 'dash-btn dash-file-btn', 'aria-haspopup': 'menu', 'aria-expanded': 'false', + title: 'File — dashboard import/export', 'aria-label': 'Dashboard File menu', + }, h('span', null, 'File'), Icon.arrow()) as HTMLButtonElement; + let menu: HTMLElement | null = null; + let overlay: HTMLElement | null = null; + + const close = (): void => { + doc.removeEventListener('keydown', onKey, true); + menu?.remove(); overlay?.remove(); + menu = null; overlay = null; + btn.setAttribute('aria-expanded', 'false'); + }; + const onKey = (e: KeyboardEvent): void => { + if (e.key === 'Escape') { e.preventDefault(); close(); btn.focus(); return; } + if (!menu) return; + const items = Array.from(menu.querySelectorAll('.dash-fm-item')); + const at = items.indexOf(doc.activeElement as HTMLButtonElement); + if (e.key === 'ArrowDown') { e.preventDefault(); items[(at + 1) % items.length]?.focus(); } + else if (e.key === 'ArrowUp') { e.preventDefault(); items[(at - 1 + items.length) % items.length]?.focus(); } + }; + + const item = (label: string, onClick: () => void): HTMLButtonElement => h('button', { + class: 'fm-item dash-fm-item', role: 'menuitem', + onclick: () => { close(); onClick(); }, + }, h('span', { class: 'fm-label' }, label)) as HTMLButtonElement; + + const open = (): void => { + overlay = h('div', { class: 'fm-overlay', onclick: close }); + const items = [item('Export Dashboard…', () => app.actions.exportDashboard())]; + if (!readOnly) { + items.push( + item('Import Dashboard…', () => app.actions.importDashboard()), + item('Open for viewing…', () => app.actions.openDashboardForViewing()), + ); + } + menu = h('div', { class: 'file-menu dash-file-menu', role: 'menu' }, ...items); + doc.body.appendChild(overlay); + doc.body.appendChild(menu); + const r = btn.getBoundingClientRect(); + const a = fixedAnchor(r) as { top: number; left: number }; + menu.style.position = 'fixed'; + menu.style.top = a.top + 'px'; + menu.style.left = a.left + 'px'; + btn.setAttribute('aria-expanded', 'true'); + doc.addEventListener('keydown', onKey, true); + items[0].focus(); + }; + + btn.onclick = () => { if (menu) { close(); btn.focus(); } else open(); }; + return btn; +} + /** Render the dashboard into `app.root`. */ export async function renderDashboard(app: DashboardApp): Promise { const { document: doc, state } = app; @@ -182,7 +274,35 @@ export async function renderDashboard(app: DashboardApp): Promise { doc.documentElement.setAttribute('data-density', state.density); app.dom = {}; - const workspace = await app.loadDashboardWorkspace(); + // #288 Phase 6: resolve WHICH dashboard this tab shows and in which MODE from + // the parsed open-source (ADR-0003). `current-workspace` (?ws=&dash=) verifies + // both ids against the shared primary store (edit) or the persistent detached + // store (view); `session-bundle` (?st=) atomically consumes the one-time + // handoff into a detached view. A bare /dashboard open is the legacy editable + // current-workspace path. A resolution failure shows not-found — never a + // different dashboard. + const source = app.dashboardOpenSource; + let workspace: StoredWorkspaceV1 | null; + let readOnly = false; + if (source && source.kind === 'session-bundle') { + workspace = await app.consumeDashboardHandoff(); + if (!workspace) { renderDashboardNotFound(app); return; } + readOnly = true; + } else if (source && source.kind === 'current-workspace') { + const primary = await app.loadDashboardWorkspace(); + const detached = await app.detachedViews.get(source.workspaceId); + const resolved = resolveDashboardMode(source, primary, detached); + if (resolved.mode === 'not-found') { renderDashboardNotFound(app); return; } + workspace = resolved.workspace; + readOnly = resolved.mode === 'view'; + } else { + workspace = await app.loadDashboardWorkspace(); + } + // Project the resolved workspace onto app.state so the Dashboard File menu's + // export/import (which read app.state) operate on THIS dashboard, not the + // tab's stale legacy-boot state. + if (workspace) app.applyCommittedWorkspace(workspace); + const queries: SavedQueryV2[] = workspace ? workspace.queries : state.savedQueries; const queryById = new Map(); for (const query of queries) if (!queryById.has(query.id)) queryById.set(query.id, query); @@ -253,15 +373,19 @@ export async function renderDashboard(app: DashboardApp): Promise { 'Dashboard layout'); const layoutWrap = h('div', { class: 'dash-layout-wrap' }, layoutSelect.el); + // #302: the Dashboard page's own resource-scoped File menu (import/export + + // open-for-viewing). #288: a read-only (detached view) tab hides the layout + // switcher — layout editing is an edit-mode-only affordance. + const fileMenuBtn = buildDashboardFileMenu(app, readOnly); const header = h('div', { class: 'dash-header' }, h('a', { class: 'dash-back', href: app.conn.basePath || '/sql', title: 'Back to SQL Browser', 'aria-label': 'Back to SQL Browser', }, Icon.arrow(), h('span', { class: 'dash-back-label' }, 'SQL Browser')), h('div', { class: 'dash-title' }, currentDoc.title || state.libraryName.value), - tileCount, layoutWrap, + tileCount, readOnly ? null : layoutWrap, h('div', { class: 'dash-spacer', style: { flex: '1' } }), h('span', { class: 'dash-chip dash-src', title: app.conn.host() }, h('span', { class: 'dash-dot' }), app.conn.host()), - updated, themeBtn, refreshBtn); + updated, fileMenuBtn, themeBtn, refreshBtn); // ── Filter bar (shared buildFilterBar, viewer-backed) ───────────────────── // #294: the field region scrolls horizontally in its own viewport @@ -351,18 +475,21 @@ export async function renderDashboard(app: DashboardApp): Promise { const head = h('div', { class: 'dash-tile-head' }, h('span', { class: 'dash-tile-name', title: ts.title }, ts.title)); const body = h('div', { class: 'dash-tile-body' }); const foot = h('div', { class: 'dash-tile-foot' }); - const card = h('div', { class: 'dash-tile', draggable: 'true' }, head, body, foot); + const card = h('div', { class: 'dash-tile', draggable: String(!readOnly) }, head, body, foot); // Pointer drag is the sole reorder mechanism (#286 owner override): a drop // persists the new dashboard.tiles[] order through the move-tile command. - card.addEventListener('dragstart', () => { dragTileId = ts.tileId; }); - card.addEventListener('dragover', (event) => event.preventDefault()); - card.addEventListener('drop', (event) => { - event.preventDefault(); - if (dragTileId && dragTileId !== ts.tileId) { - runCommand({ type: 'move-tile', tileId: dragTileId, toIndex: currentDoc.tiles.map((t) => t.id).indexOf(ts.tileId) }); - } - dragTileId = null; - }); + // A read-only (detached view) dashboard is not reorderable (#288). + if (!readOnly) { + card.addEventListener('dragstart', () => { dragTileId = ts.tileId; }); + card.addEventListener('dragover', (event) => event.preventDefault()); + card.addEventListener('drop', (event) => { + event.preventDefault(); + if (dragTileId && dragTileId !== ts.tileId) { + runCommand({ type: 'move-tile', tileId: dragTileId, toIndex: currentDoc.tiles.map((t) => t.id).indexOf(ts.tileId) }); + } + dragTileId = null; + }); + } const tileEl: TileEl = { card, body, foot, panelState: null, destroy: null, paintedRows: null }; tileEls.set(ts.tileId, tileEl); return tileEl; diff --git a/src/ui/file-menu.ts b/src/ui/file-menu.ts index 38b2f6c2..1862426a 100644 --- a/src/ui/file-menu.ts +++ b/src/ui/file-menu.ts @@ -20,7 +20,7 @@ import { Icon } from './icons.js'; import { flashToast } from './toast.js'; import { renderSavedHistory } from './saved-history.js'; import { buildMarkdownDoc, buildSqlDoc } from '../core/saved-io.js'; -import { queryFavorite, queryName } from '../core/saved-query.js'; +import { queryName } from '../core/saved-query.js'; import { decodePortableBundleJson, encodePortableBundleJson } from '../dashboard/model/portable-bundle-codec.js'; import { normalizeLegacyLibraryToBundle } from '../dashboard/model/legacy-bundle.js'; import { buildDashboardExportBundle, buildWorkspaceExportBundle } from '../dashboard/model/dashboard-export.js'; @@ -52,7 +52,26 @@ export function libraryControls(app: App): HTMLElement[] { }, h('span', null, 'File'), Icon.chevDown()); app.dom.libraryTitle = h('div', { class: 'lib-title' }); renderLibraryTitle(app); - return [app.dom.fileBtn, app.dom.libraryTitle]; + // #302: Dashboard navigation lives next to the workspace name (not in the + // File menu). A plain navigation control (not a dropdown) that opens the + // standalone Dashboard; shown only when the workspace has a Dashboard. The + // label collapses to icon-only on narrow screens via `hd-hide-mobile-label`, + // but the accessible name ("Open Dashboard") is always present. + app.dom.dashboardNav = h('button', { + class: 'hd-dash-nav', title: 'Open Dashboard', 'aria-label': 'Open Dashboard', + onclick: () => app.actions.openDashboard(), + }, Icon.layers(), h('span', { class: 'hd-dash-nav-label hd-hide-mobile-label' }, 'Dashboard →')); + renderDashboardNav(app); + return [app.dom.fileBtn, app.dom.libraryTitle, app.dom.dashboardNav]; +} + +/** #302 — show the header "Dashboard →" control only when the current workspace + * has a Dashboard. Re-run on library changes (see the workbench-shell effect + + * `afterLibraryChange`). No-op before `libraryControls` has built the slot. */ +export function renderDashboardNav(app: App): void { + const nav = app.dom.dashboardNav; + if (!nav) return; + nav.hidden = !app.state.dashboard; } /** (Re)render the workspace title into its slot: a click-to-rename name button @@ -107,25 +126,18 @@ export function openFileMenu(app: App): void { const onKey = (e: KeyboardEvent): void => { if (e.key === 'Escape') { e.preventDefault(); close(); } }; const importQueriesInput = pickerInput(app, (f) => onImportQueriesFile(app, f)); - const importDashboardInput = pickerInput(app, (f) => onImportDashboardFile(app, f)); const replaceWorkspaceInput = pickerInput(app, (f) => onReplaceWorkspaceFile(app, f)); const item = (icon: Node, label: string, meta: string | null, onClick: () => void): HTMLButtonElement => h('button', { class: 'fm-item', onclick: onClick }, h('span', { class: 'fm-icon' }, icon), h('span', { class: 'fm-label' }, label), meta ? h('span', { class: 'fm-meta' }, meta) : null); const sep = (): HTMLDivElement => h('div', { class: 'fm-sep' }); const empty = list.length === 0; - const hasFav = list.some(queryFavorite); - const hasDashboard = !!app.state.dashboard; + // #302: the Workbench File menu owns workspace + query-collection operations + // ONLY. Dashboard navigation moved to the header "Dashboard →" control + // (`libraryControls`), and Dashboard import/export moved to the Dashboard + // page's own File menu — none of them appear here anymore. const newWorkspaceItem = item(Icon.plus(), 'New workspace…', null, () => { close(); newWorkspaceAction(app); }); - // Open the favorited subset of the workspace as a standalone dashboard - // (#149). Enabled only when at least one query is starred; otherwise it - // explains why. - const dashboardItem = item(Icon.layers(), 'Open as dashboard', hasFav ? null : 'no favorites', () => { - close(); - if (!hasFav) { flashToast('Star a query to add it to the dashboard', { document: app.document }); return; } - app.actions.openDashboard(); - }); // Variable recent-value history (#171): this is the closest thing the app // has to a "settings" surface today (no dedicated preferences panel exists // yet — rg for one turned up nothing), so it follows the File menu's own @@ -151,31 +163,22 @@ export function openFileMenu(app: App): void { const menu = h('div', { class: 'file-menu' }, newWorkspaceItem, sep(), - h('div', { class: 'fm-section' }, 'Dashboard'), - dashboardItem, - sep(), h('div', { class: 'fm-section' }, 'Variable history'), historyToggle, clearAllRecentItem, sep(), h('div', { class: 'fm-section' }, 'Import / replace'), item(Icon.upload(), 'Import queries…', null, () => { importQueriesInput.click(); close(); }), - item(Icon.upload(), 'Import Dashboard…', null, () => { importDashboardInput.click(); close(); }), item(Icon.refresh(), 'Replace workspace…', null, () => { replaceWorkspaceInput.click(); close(); }), sep(), h('div', { class: 'fm-section' }, 'Export'), - item(Icon.download(), 'Export Dashboard…', hasDashboard ? '.json' : 'no dashboard', () => { - close(); - if (!hasDashboard) { flashToast('No dashboard to export', { document: app.document }); return; } - exportDashboardAction(app); - }), item(Icon.download(), 'Export workspace…', '.json', () => { close(); exportWorkspaceAction(app); }), sep(), h('div', { class: 'fm-section' }, 'Share / publish'), item(Icon.download(), 'Download Markdown', '.md', () => { close(); downloadAction(app, 'md'); }), item(Icon.download(), 'Download SQL', '.sql', () => { close(); downloadAction(app, 'sql'); }), h('div', { class: 'fm-count' }, empty ? 'Workspace is empty' : queries(list.length) + ' in workspace'), - importQueriesInput, importDashboardInput, replaceWorkspaceInput); + importQueriesInput, replaceWorkspaceInput); const overlay = h('div', { class: 'fm-overlay', onclick: close }); app.dom.fileMenuOverlay = overlay; @@ -271,12 +274,19 @@ function currentWorkspace(app: App): StoredWorkspaceV1 { * may be pruned) and the saved list (count + rows). The title (name + dirty * dot) repaints itself via the libraryName/libraryDirty effect in createApp. */ function afterLibraryChange(app: App): void { + // #302: on the standalone Dashboard route the only committing operation is + // Import Dashboard, and none of the Workbench chrome below exists — re-render + // the dashboard instead (repointing the URL at the possibly-new dashboard id). + if (app.dashboardRoute) { app.reloadDashboardRoute(); return; } app.updateSaveBtn(); // Always defined by the time a file-menu action can run (post-boot, // post-first-renderApp()) — app.types.ts only marks it optional because it's // absent before that first render. app.updateEditorModeUi!(); renderSavedHistory(app); + // Keep the header "Dashboard →" control in sync when a commit adds/removes + // the workspace's Dashboard (e.g. Replace workspace / Import queries). + renderDashboardNav(app); } /** Commit one whole candidate `StoredWorkspaceV1`, then project + repaint on @@ -460,6 +470,21 @@ function onImportDashboardFile(app: App, file: File): void { readBundleFile(app, file, (bundle) => startImportDashboard(app, bundle)); } +/** #302 — programmatically trigger the "Import Dashboard…" flow (used by the + * Dashboard page's own File menu via `app.actions.importDashboard`): open a + * file picker, then run the same transactional import as the Workbench used to. + * On success `commitPlan` → `afterLibraryChange` repaints the active surface + * (the Dashboard route re-renders itself). */ +export function triggerImportDashboard(app: App): void { + const input = pickerInput(app, (f) => onImportDashboardFile(app, f)); + // Self-remove once a selection fires so repeated imports don't leak hidden + // inputs onto the page (the Workbench menu's own pickers are cleaned up with + // the menu; this one has no menu to ride along with). + input.addEventListener('change', () => input.remove(), { once: true }); + app.document.body.appendChild(input); + input.click(); +} + function startImportDashboard(app: App, bundle: PortableBundleV1): void { const dashboards = listBundleDashboards(bundle); if (!dashboards.length) { flashToast('✕ No dashboard in file', { document: app.document }); return; } @@ -548,11 +573,12 @@ function downloadEncodedBundle(app: App, bundle: PortableBundleV1, baseName: str flashToast('Exported → .json', { document: app.document }); } -function exportDashboardAction(app: App): void { - // `!`: the only call site (the "Export Dashboard…" item's onclick, above) - // already checked `hasDashboard` and returns early with its own toast - // otherwise — this is never invoked with a null Dashboard. - const dashboard = app.state.dashboard!; +export function exportDashboardAction(app: App): void { + // #302: invoked from the Dashboard page's File menu (via + // `app.actions.exportDashboard`). Guard a null Dashboard here — unlike the + // old Workbench menu item, the caller no longer pre-checks `hasDashboard`. + const dashboard = app.state.dashboard; + if (!dashboard) { flashToast('No dashboard to export', { document: app.document }); return; } const bundle = buildDashboardExportBundle(dashboard, app.state.savedQueries, new Date(app.wallNow()).toISOString()); downloadEncodedBundle(app, bundle, dashboard.title || app.state.libraryName.value); } diff --git a/src/ui/workbench/workbench-shell.ts b/src/ui/workbench/workbench-shell.ts index 0565b7c7..385adf3b 100644 --- a/src/ui/workbench/workbench-shell.ts +++ b/src/ui/workbench/workbench-shell.ts @@ -57,7 +57,7 @@ import { renderSchema } from '../schema.js'; import { renderResults } from '../results.js'; import type { QueryResult } from '../results.js'; import { renderSavedHistory } from '../saved-history.js'; -import { libraryControls, renderLibraryTitle } from '../file-menu.js'; +import { libraryControls, renderLibraryTitle, renderDashboardNav } from '../file-menu.js'; import { SCHEMA_GRAPH_MIME } from '../dnd-mime.js'; import { startDrag } from '../splitters.js'; import type { DragCtx, DragRect, DragStartEvent, SplitterAxis } from '../splitters.js'; @@ -394,6 +394,10 @@ export function mountWorkbenchShell(deps: WorkbenchShellDeps): void { state.libraryName.value; state.libraryDirty.value; renderLibraryTitle(app); + // #302: the "Dashboard →" control's visibility tracks Dashboard presence, + // which changes alongside these signals (star toggle / import / replace all + // flip libraryDirty on their way through a commit). + renderDashboardNav(app); }); // Mobile mode (#126): mirror the viewport width into `isMobile` (drives the // schema tree's drag/hover affordances, the results drop target, and the diff --git a/src/workspace/detached-views-store.types.ts b/src/workspace/detached-views-store.types.ts new file mode 100644 index 00000000..f403898c --- /dev/null +++ b/src/workspace/detached-views-store.types.ts @@ -0,0 +1,25 @@ +// The injected persistence seam for detached VIEW-mode Dashboard snapshots +// (#288 Phase 6 handoff). A detached view is a read-only copy of a Dashboard +// materialized into its own store under a fresh workspace id — distinct from +// the single shared primary `asb-workspace` aggregate (`WorkspaceStore`). +// Many records may accumulate over time (one per "Open for viewing…"), so +// unlike the primary store this seam is a small keyed collection with a +// retention cap, not a single aggregate record. +// +// Type-only (ADR-0002 seam contract) — no executable statements, excluded +// from the coverage gate like every other `*.types.ts`. +import type { StoredWorkspaceV1 } from '../generated/json-schema.types.js'; + +export interface DetachedViewRecord { + workspace: StoredWorkspaceV1; + savedAt: number; +} + +export interface DetachedViewsStore { + /** Persist a detached read-only workspace snapshot, keyed by + * `record.workspace.id`. After writing, prune the store to the newest + * `maxRecords` (by `savedAt`) so it cannot grow unbounded. */ + put(record: DetachedViewRecord): Promise; + /** Load one detached workspace by id, or `null` when absent. */ + get(id: string): Promise; +} diff --git a/src/workspace/handoff-store.types.ts b/src/workspace/handoff-store.types.ts new file mode 100644 index 00000000..82476f06 --- /dev/null +++ b/src/workspace/handoff-store.types.ts @@ -0,0 +1,33 @@ +// The injected persistence seam for the one-time dashboard view-mode handoff +// token (#288). A handoff record carries a portable-bundle snapshot's landing +// coordinates (the detached workspace it will materialize into) across a new +// tab's navigation — read exactly once, then gone, so the `?st=` URL is never +// bookmarkable or replayable. +// +// Exactly like `WorkspaceStore`: the consumer depends only on this narrow +// async interface, never on a concrete IndexedDB, so it is unit-testable with +// a plain in-memory fake. +// +// Type-only (ADR-0002 seam contract) — no executable statements, excluded +// from the coverage gate like every other `*.types.ts`. +export interface HandoffRecord { + /** Opaque payload text (the caller's serialized PortableBundleV1 handoff). */ + text: string; + /** The dashboard id the handoff is for. */ + dashboardId: string; + /** The fresh detached workspace id the token will materialize into. */ + detachedWorkspaceId: string; + /** Absolute epoch-ms expiry; `take` treats an expired record as absent. */ + expiresAt: number; +} + +export interface HandoffStore { + /** Write one token record (readwrite transaction). Overwrites any existing + * record under the same token. */ + put(token: string, record: HandoffRecord): Promise; + /** Atomically get-and-DELETE the record for `token` in ONE readwrite + * transaction, then resolve it only if not expired (`expiresAt > nowMs`); + * otherwise resolve `null`. The record is deleted whether or not it was + * expired, and resolves `null` when no record exists for `token`. */ + take(token: string, nowMs: number): Promise; +} diff --git a/src/workspace/indexeddb-detached-views-store.ts b/src/workspace/indexeddb-detached-views-store.ts new file mode 100644 index 00000000..35793a81 --- /dev/null +++ b/src/workspace/indexeddb-detached-views-store.ts @@ -0,0 +1,135 @@ +// Concrete IndexedDB adapter behind the injected `DetachedViewsStore` seam +// (#288 Phase 6 — VIEW-mode Dashboard handoff). Each detached view is a +// read-only snapshot of one Dashboard, keyed by its own fresh workspace id, +// stored in a DEDICATED database separate from the single shared primary +// `asb-workspace` aggregate — a detached view is not the editable primary +// workspace and must never be reachable through that store's key. Because +// "Open for viewing…" can be used repeatedly, this store is a small keyed +// collection with a retention cap (newest `maxRecords` by `savedAt`), unlike +// the primary store's single replaced record. +// +// Same adapter shape as `indexeddb-workspace-store.ts` (lazy cached `openDb`, +// `requestResult`, `transactionDone`, cache dropped on a failed open) — see +// that file for the seam-pattern rationale. + +import type { DetachedViewRecord, DetachedViewsStore } from './detached-views-store.types.js'; +import type { StoredWorkspaceV1 } from '../generated/json-schema.types.js'; + +export interface IndexedDbDetachedViewsStoreOptions { + /** IndexedDB database name. */ + dbName?: string; + /** Object-store name inside the database. */ + storeName?: string; + /** Retention cap: newest records (by `savedAt`) kept after each `put`. */ + maxRecords?: number; +} + +const DEFAULTS = { + dbName: 'asb-dashboard-views', + storeName: 'views', + maxRecords: 20, +} as const; + +// Promisify one IDBRequest. +function requestResult(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed')); + }); +} + +// Resolve when a readwrite transaction durably completes (the atomicity point). +function transactionDone(tx: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error ?? new Error('IndexedDB transaction failed')); + tx.onabort = () => reject(tx.error ?? new Error('IndexedDB transaction aborted')); + }); +} + +interface StoredEntry { key: IDBValidKey; savedAt: number; } + +// Prune the store to the newest `maxRecords` by `savedAt`, via one cursor walk. +// The stale deletes are issued INSIDE the cursor's terminal callback (where the +// full key+savedAt set is known and the transaction is still active) — never in +// an awaited continuation, which would let the transaction auto-commit first +// and make `delete` throw in a real browser. Records carry no index, and the +// retention cap keeps the store small enough for a full scan. +function pruneToCap(store: IDBObjectStore, maxRecords: number): Promise { + return new Promise((resolve, reject) => { + const entries: StoredEntry[] = []; + const request = store.openCursor(); + request.onsuccess = () => { + const cursor = request.result; + if (cursor) { + const record = cursor.value as DetachedViewRecord; + entries.push({ key: cursor.key, savedAt: record.savedAt }); + cursor.continue(); + return; + } + if (entries.length > maxRecords) { + const newestFirst = [...entries].sort((a, b) => b.savedAt - a.savedAt); + for (const stale of newestFirst.slice(maxRecords)) store.delete(stale.key); + } + resolve(); + }; + request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed')); + }); +} + +/** Build a `DetachedViewsStore` backed by IndexedDB. The database is opened + * lazily on first use (and the open promise cached), so constructing the + * store with a not-yet-available factory never throws — the failure + * surfaces only when an operation actually runs. */ +export function createIndexedDbDetachedViewsStore( + factory: IDBFactory | undefined, options: IndexedDbDetachedViewsStoreOptions = {}, +): DetachedViewsStore { + const dbName = options.dbName ?? DEFAULTS.dbName; + const storeName = options.storeName ?? DEFAULTS.storeName; + const maxRecords = options.maxRecords ?? DEFAULTS.maxRecords; + let dbPromise: Promise | null = null; + + function openDb(): Promise { + if (dbPromise) return dbPromise; + const pending = new Promise((resolve, reject) => { + if (!factory) { + reject(new Error('IndexedDB is unavailable')); + return; + } + const request = factory.open(dbName, 1); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(storeName)) db.createObjectStore(storeName); + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error('IndexedDB open failed')); + // Dormant at version 1 (no upgrade can block a fresh open), but an + // unhandled `blocked` event would hang the open forever — reject so the + // no-cache-on-failure path below reopens on the next call. + request.onblocked = () => reject(new Error('IndexedDB open blocked')); + }); + // Cache only a SUCCESSFUL open; drop it on failure so a same-session + // retry can reopen (matches the primary workspace store's contract). + dbPromise = pending; + pending.catch(() => { if (dbPromise === pending) dbPromise = null; }); + return pending; + } + + async function put(record: DetachedViewRecord): Promise { + const db = await openDb(); + const tx = db.transaction([storeName], 'readwrite'); + const store = tx.objectStore(storeName); + store.put(record, record.workspace.id); + await pruneToCap(store, maxRecords); + await transactionDone(tx); + } + + async function get(id: string): Promise { + const db = await openDb(); + const tx = db.transaction([storeName], 'readonly'); + const record = await requestResult(tx.objectStore(storeName).get(id)); + return record ? record.workspace : null; + } + + return { put, get }; +} diff --git a/src/workspace/indexeddb-handoff-store.ts b/src/workspace/indexeddb-handoff-store.ts new file mode 100644 index 00000000..5c7c0047 --- /dev/null +++ b/src/workspace/indexeddb-handoff-store.ts @@ -0,0 +1,117 @@ +// Concrete IndexedDB adapter behind the injected `HandoffStore` seam (#288 +// Dashboard view-mode handoff). Structured exactly like +// `indexeddb-workspace-store.ts` (lazy cached `openDb`, `requestResult`, +// `transactionDone`, cache dropped on a failed open) but backed by its OWN +// dedicated database — never the `asb-workspace` DB — since a handoff token +// is a short-lived, one-time-consumed record with its own lifecycle, unlike +// the workspace aggregate's single-record replace-whole-thing semantics. +// +// The `IDBFactory` is injected the same way as the workspace store, so tests +// drive this with a minimal in-memory fake factory instead of a real browser +// database. + +import type { HandoffRecord, HandoffStore } from './handoff-store.types.js'; + +export interface IndexedDbHandoffStoreOptions { + /** IndexedDB database name. */ + dbName?: string; + /** Object-store name inside the database. */ + storeName?: string; +} + +const DEFAULTS = { + dbName: 'asb-dashboard-handoff', + storeName: 'handoff', +} as const; + +// Promisify one IDBRequest. +function requestResult(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed')); + }); +} + +// Resolve when a readwrite transaction durably completes (the atomicity point). +function transactionDone(tx: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error ?? new Error('IndexedDB transaction failed')); + tx.onabort = () => reject(tx.error ?? new Error('IndexedDB transaction aborted')); + }); +} + +/** Build a `HandoffStore` backed by IndexedDB. The database is opened lazily + * on first use (and the open promise cached), so constructing the store with + * a not-yet-available factory never throws — the failure surfaces only when + * an operation actually runs, where the caller catches it. */ +export function createIndexedDbHandoffStore( + factory: IDBFactory | undefined, options: IndexedDbHandoffStoreOptions = {}, +): HandoffStore { + const dbName = options.dbName ?? DEFAULTS.dbName; + const storeName = options.storeName ?? DEFAULTS.storeName; + let dbPromise: Promise | null = null; + + function openDb(): Promise { + if (dbPromise) return dbPromise; + const pending = new Promise((resolve, reject) => { + if (!factory) { + reject(new Error('IndexedDB is unavailable')); + return; + } + const request = factory.open(dbName, 1); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(storeName)) db.createObjectStore(storeName); + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error('IndexedDB open failed')); + // Dormant at version 1 (no upgrade can block a fresh open), but an + // unhandled `blocked` event would hang the open forever — reject so the + // no-cache-on-failure path below reopens on the next call. + request.onblocked = () => reject(new Error('IndexedDB open blocked')); + }); + // Cache only a SUCCESSFUL open — a rejected open must not poison the + // store for the page's lifetime; drop the cached promise on failure so a + // same-session retry can reopen. + dbPromise = pending; + pending.catch(() => { if (dbPromise === pending) dbPromise = null; }); + return pending; + } + + async function put(token: string, record: HandoffRecord): Promise { + const db = await openDb(); + const tx = db.transaction([storeName], 'readwrite'); + // growth note: `put` deliberately does not sweep expired records (no + // cursor scan here). A handoff token is single-use and short-lived by + // construction — the only paths that remove a record are `take` + // (consumed) or the browser evicting the whole origin's IndexedDB under + // storage pressure. Unconsumed-token growth is therefore bounded by how + // often a view-mode link is generated and then abandoned before ever + // being opened, not by ordinary app traffic — acceptable for this leaf, + // minimal adapter (kept 100%-coverable rather than adding an unexercised + // sweep branch). + tx.objectStore(storeName).put(record, token); + await transactionDone(tx); + } + + async function take(token: string, nowMs: number): Promise { + const db = await openDb(); + const tx = db.transaction([storeName], 'readwrite'); + const store = tx.objectStore(storeName); + // Issue BOTH the get and the delete synchronously while the transaction is + // still active — NEVER `await` between them. Awaiting a request mid-txn lets + // the transaction auto-commit (it goes inactive when control yields with no + // pending request), so the paired `delete` would throw + // `TransactionInactiveError` in a real browser. Awaiting the get's result + // *after* both requests are queued is safe — the pending delete keeps the + // transaction alive — and a get-request error still rejects here. + const getReq = store.get(token); + store.delete(token); + const record = await requestResult(getReq); + await transactionDone(tx); + return record && record.expiresAt > nowMs ? record : null; + } + + return { put, take }; +} diff --git a/tests/helpers/fake-app.ts b/tests/helpers/fake-app.ts index 6b549460..ddc04fca 100644 --- a/tests/helpers/fake-app.ts +++ b/tests/helpers/fake-app.ts @@ -287,6 +287,20 @@ const appDefaults: App = { }), clearCurrent: async () => {}, }, + // #288 Phase 6 — Dashboard viewing seams. In-memory no-op stores by default; + // a test exercising the handoff/detached-view flow overrides these. + handoff: { + put: async () => {}, + take: async () => null, + }, + detachedViews: { + put: async () => {}, + get: async () => null, + }, + dashboardOpenSource: null, + dashboardRoute: false, + reloadDashboardRoute: () => {}, + consumeDashboardHandoff: async () => null, loadDashboardWorkspace: async () => null, loadWorkspaceOnBoot: async () => null, // Inert placeholders — `base` below overrides both with real, state-backed @@ -359,6 +373,7 @@ const appDefaults: App = { renderApp: () => {}, renderDashboard: () => {}, openDashboard: () => {}, + openDashboardForViewing: () => {}, actions: {} as ActionsRegistry, }; @@ -575,6 +590,9 @@ export function makeApp>(override openCreateInNewTab: vi.fn(), openShortcuts: vi.fn(), openDashboard: vi.fn(), + openDashboardForViewing: vi.fn(), + exportDashboard: vi.fn(), + importDashboard: vi.fn(), openUserMenu: vi.fn(), insertAtCursor: vi.fn(), replaceEditor: vi.fn(), diff --git a/tests/unit/app.test.ts b/tests/unit/app.test.ts index 9d8f8f81..1cbe8b2b 100644 --- a/tests/unit/app.test.ts +++ b/tests/unit/app.test.ts @@ -13,6 +13,7 @@ import { queryDescription } from '../../src/core/saved-query.js'; import { createSpecValidatorRegistry } from '../../src/core/spec-draft.js'; import { savedQuery } from '../helpers/saved-query.js'; import { fakeIndexedDbFactory } from '../helpers/fake-idb.js'; +import { encodePortableBundleJson } from '../../src/dashboard/model/portable-bundle-codec.js'; import { decodeShare } from '../../src/core/share.js'; import type { CreateAppEnv } from '../../src/env.types.js'; import type { App } from '../../src/ui/app.types.js'; @@ -4371,3 +4372,132 @@ describe('mobile best-effort mode (#126)', () => { expect(over.defaultPrevented).toBe(false); // guard returns before preventDefault }); }); + +// ── #288 / #302: Dashboard viewing seams on the App controller ──────────────── +describe('Dashboard viewing (open-source, handoff, actions) — #288/#302', () => { + const vdash = () => ({ + documentVersion: 1, id: 'd', title: 'My View', revision: 1, + layout: { type: 'flow', version: 1, preset: 'full-width', items: { t1: {} } }, + filters: [], tiles: [{ id: 't1', queryId: 'q1' }], + }); + const vquery = () => savedQuery({ id: 'q1', name: 'q1', sql: 'SELECT 1' }); + const bundleText = (): string => { + const enc = encodePortableBundleJson({ queries: [vquery()], dashboards: [vdash()] as never, nowISO: '2026-07-18T00:00:00.000Z' }); + if (!enc.ok) throw new Error('fixture failed to encode'); + return enc.value; + }; + const child = () => ({ postMessage: vi.fn(), closed: false }); + + it('createApp parses the tab open-source + route flag from the location', () => { + const editTab = createApp(env({ location: { origin: 'https://ch.example', pathname: '/sql/dashboard', search: '?ws=w1&dash=d1', host: 'ch.example' } as Location })); + expect(editTab.dashboardOpenSource).toEqual({ kind: 'current-workspace', workspaceId: 'w1', dashboardId: 'd1' }); + expect(editTab.dashboardRoute).toBe(true); + const workbenchTab = createApp(env()); + expect(workbenchTab.dashboardOpenSource).toBeNull(); + expect(workbenchTab.dashboardRoute).toBe(false); + }); + + it('openDashboard opens ?ws=&dash= when the workspace has a dashboard, else the bare route; grants the handoff', () => { + const opened: (string | undefined)[] = []; + const c = child(); + const app = createApp(env({ openWindow: asOpenWindow((url?: string) => { opened.push(url); return c; }) })); + app.state.workspaceId = 'w9'; + app.state.dashboard = vdash() as never; + app.openDashboard(); + expect(opened[0]).toBe('https://ch.example/sql/dashboard?ws=w9&dash=d'); + expect(c.postMessage).not.toBe(undefined); // grantHandoffTo ran against the child + // No dashboard → bare route. + app.state.dashboard = null; + app.openDashboard(); + expect(opened[1]).toBe('https://ch.example/sql/dashboard'); + }); + + it('openDashboardForViewing writes the one-time token then opens ?st=; toasts when there is no dashboard', async () => { + const opened: (string | undefined)[] = []; + const put = vi.fn(async () => {}); + const app = createApp(env({ openWindow: asOpenWindow((url?: string) => { opened.push(url); return child(); }) })); + app.handoff = { put, take: vi.fn(async () => null) }; + app.state.savedQueries = [vquery()] as never; + app.state.dashboard = vdash() as never; + app.openDashboardForViewing(); + expect(opened[0]).toMatch(/\/dashboard\?st=[0-9a-f]{64}&dash=d$/); + expect(put).toHaveBeenCalledOnce(); + // No dashboard → toast, no window. + app.state.dashboard = null; + app.openDashboardForViewing(); + expect(opened.length).toBe(1); + }); + + it('openDashboardForViewing toasts on an unencodable dashboard and on a failed token write', async () => { + const opened: unknown[] = []; + // Unencodable dashboard (bad preset) → build fails before opening a window. + const app = createApp(env({ openWindow: asOpenWindow((url?: string) => { opened.push(url); return child(); }) })); + app.handoff = { put: vi.fn(async () => {}), take: vi.fn(async () => null) }; + app.state.savedQueries = [vquery()] as never; + app.state.dashboard = { ...vdash(), layout: { type: 'flow', version: 1, preset: 'nope', items: {} } } as never; + app.openDashboardForViewing(); + expect(opened.length).toBe(0); + // Valid dashboard but the token write rejects → the window still opened, the + // rejection is caught (toast), never thrown. + const app2 = createApp(env({ openWindow: asOpenWindow(() => child()) })); + app2.handoff = { put: vi.fn(async () => { throw new Error('idb down'); }), take: vi.fn(async () => null) }; + app2.state.savedQueries = [vquery()] as never; + app2.state.dashboard = vdash() as never; + app2.openDashboardForViewing(); + await new Promise((r) => setTimeout(r, 0)); // let the rejected put settle into .catch + }); + + it('consumeDashboardHandoff atomically consumes the token, materializes a detached view, and rewrites the URL', async () => { + const app = createApp(env({ location: { origin: 'https://ch.example', pathname: '/sql/dashboard', search: '?st=tok&dash=d', host: 'ch.example' } as Location })); + const put = vi.fn(async () => {}); + app.handoff = { take: vi.fn(async () => ({ text: bundleText(), dashboardId: 'd', detachedWorkspaceId: 'wsview-1', expiresAt: 9e12 })), put: vi.fn(async () => {}) }; + app.detachedViews = { get: vi.fn(async () => null), put }; + // happy-dom's real replaceState rejects a cross-origin URL from the test's + // blob: document — stub the impl so we observe the call without it throwing. + const replaceState = vi.spyOn(window.history, 'replaceState').mockImplementation(() => {}); + const ws = await app.consumeDashboardHandoff(); + expect(ws?.id).toBe('wsview-1'); + expect(put).toHaveBeenCalledOnce(); + expect(replaceState).toHaveBeenCalled(); + expect(app.dashboardOpenSource).toEqual({ kind: 'current-workspace', workspaceId: 'wsview-1', dashboardId: 'd' }); + replaceState.mockRestore(); + }); + + it('consumeDashboardHandoff returns null for a non-bundle route, a spent token, and an undecodable record', async () => { + const plain = createApp(env()); // no ?st → not a session-bundle route + expect(await plain.consumeDashboardHandoff()).toBeNull(); + const app = createApp(env({ location: { origin: 'https://ch.example', pathname: '/sql/dashboard', search: '?st=tok&dash=d', host: 'ch.example' } as Location })); + app.handoff = { take: vi.fn(async () => null), put: vi.fn(async () => {}) }; // spent/expired + expect(await app.consumeDashboardHandoff()).toBeNull(); + app.handoff = { take: vi.fn(async () => ({ text: '{bad', dashboardId: 'd', detachedWorkspaceId: 'x', expiresAt: 9e12 })), put: vi.fn(async () => {}) }; + expect(await app.consumeDashboardHandoff()).toBeNull(); + }); + + it('reloadDashboardRoute repoints the URL at the current dashboard and re-renders (URL skipped when absent)', () => { + const app = createApp(env({ location: { origin: 'https://ch.example', pathname: '/sql/dashboard', search: '?ws=w&dash=old', host: 'ch.example' } as Location })); + app.renderDashboard = vi.fn(); + const replaceState = vi.spyOn(window.history, 'replaceState').mockImplementation(() => {}); + app.state.workspaceId = 'w'; + app.state.dashboard = { ...vdash(), id: 'dNew' } as never; + app.reloadDashboardRoute(); + expect(replaceState).toHaveBeenCalledWith(null, '', 'https://ch.example/sql/dashboard?ws=w&dash=dNew'); + expect(app.dashboardOpenSource).toEqual({ kind: 'current-workspace', workspaceId: 'w', dashboardId: 'dNew' }); + expect(app.renderDashboard).toHaveBeenCalledOnce(); + // No dashboard → re-render only, no URL rewrite. + replaceState.mockClear(); + app.state.dashboard = null; + app.reloadDashboardRoute(); + expect(replaceState).not.toHaveBeenCalled(); + expect(app.renderDashboard).toHaveBeenCalledTimes(2); + replaceState.mockRestore(); + }); + + it('the Dashboard export/import actions delegate to their file-menu flows', () => { + const app = createApp(env()); + // exportDashboard with no dashboard just toasts (no throw); importDashboard + // opens a picker input on the page — both exercise the action arrow bodies. + app.state.dashboard = null; + expect(() => app.actions.exportDashboard()).not.toThrow(); + expect(() => app.actions.importDashboard()).not.toThrow(); + }); +}); diff --git a/tests/unit/dashboard-boundaries.test.js b/tests/unit/dashboard-boundaries.test.js index 3db5725f..35a351f7 100644 --- a/tests/unit/dashboard-boundaries.test.js +++ b/tests/unit/dashboard-boundaries.test.js @@ -83,4 +83,13 @@ describe('dashboard dependency boundaries', () => { const specs = relativeSpecifiers(file).map((spec) => resolveSpec(file, spec)); expect(specs.some((spec) => FORBIDDEN.some((f) => spec === f || spec.startsWith(`${f}/`)))).toBe(false); }); + + // #288 Phase 6: the workspace aggregate + Dashboard-viewing stores (the + // one-time handoff transport and the persistent detached-views store) are the + // read-only viewer path's persistence layer — they must stay constructible + // without any UI/App/editor/service/net dependency, so the viewer bundles no + // Workbench/editor construction. + it('src/workspace imports no Workbench UI / App / AppState / editor / service / net modules', () => { + expect(violations('src/workspace')).toEqual([]); + }); }); diff --git a/tests/unit/dashboard-open-source.test.ts b/tests/unit/dashboard-open-source.test.ts new file mode 100644 index 00000000..41ec592b --- /dev/null +++ b/tests/unit/dashboard-open-source.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from 'vitest'; +import { + parseDashboardOpenSource, buildDashboardSearch, +} from '../../src/dashboard/application/dashboard-open-source.js'; +import type { DashboardOpenSource } from '../../src/dashboard/application/dashboard-open-source.js'; + +describe('parseDashboardOpenSource', () => { + it('resolves a current-workspace source from ?ws=&dash=', () => { + expect(parseDashboardOpenSource('?ws=abc&dash=def')).toEqual({ + kind: 'current-workspace', workspaceId: 'abc', dashboardId: 'def', + }); + }); + + it('resolves a session-bundle source from ?st=&dash=', () => { + expect(parseDashboardOpenSource('?st=tok123&dash=def')).toEqual({ + kind: 'session-bundle', token: 'tok123', dashboardId: 'def', + }); + }); + + it('prefers st over ws when both are present', () => { + expect(parseDashboardOpenSource('?st=tok&ws=abc&dash=def')).toEqual({ + kind: 'session-bundle', token: 'tok', dashboardId: 'def', + }); + }); + + it('defaults a missing dash to the empty string', () => { + expect(parseDashboardOpenSource('?ws=abc')).toEqual({ + kind: 'current-workspace', workspaceId: 'abc', dashboardId: '', + }); + expect(parseDashboardOpenSource('?st=tok')).toEqual({ + kind: 'session-bundle', token: 'tok', dashboardId: '', + }); + }); + + it('returns null for the legacy bare /dashboard open (no ws, no st)', () => { + expect(parseDashboardOpenSource('?dash=def')).toBeNull(); + }); + + it('returns null for empty, ?-only, or garbage search strings', () => { + expect(parseDashboardOpenSource('')).toBeNull(); + expect(parseDashboardOpenSource('?')).toBeNull(); + expect(parseDashboardOpenSource('garbage')).toBeNull(); + }); + + it('treats an empty ws or st value as absent (falls through)', () => { + expect(parseDashboardOpenSource('?ws=&dash=def')).toBeNull(); + expect(parseDashboardOpenSource('?st=&ws=abc&dash=def')).toEqual({ + kind: 'current-workspace', workspaceId: 'abc', dashboardId: 'def', + }); + }); + + it('URL-decodes ws, st, and dash', () => { + expect(parseDashboardOpenSource('?ws=a%20b&dash=c%2Fd')).toEqual({ + kind: 'current-workspace', workspaceId: 'a b', dashboardId: 'c/d', + }); + expect(parseDashboardOpenSource('?st=tok%2B1&dash=x')).toEqual({ + kind: 'session-bundle', token: 'tok+1', dashboardId: 'x', + }); + }); + + it('ignores the OAuth CSRF `state` param — never treated as a token', () => { + expect(parseDashboardOpenSource('?state=csrf-value&dash=def')).toBeNull(); + // `state` present alongside a real source must not interfere. + expect(parseDashboardOpenSource('?state=csrf-value&ws=abc&dash=def')).toEqual({ + kind: 'current-workspace', workspaceId: 'abc', dashboardId: 'def', + }); + }); + + it('accepts a search string without a leading ?', () => { + expect(parseDashboardOpenSource('ws=abc&dash=def')).toEqual({ + kind: 'current-workspace', workspaceId: 'abc', dashboardId: 'def', + }); + }); +}); + +describe('buildDashboardSearch', () => { + it('builds ?ws=&dash= for a current-workspace source', () => { + expect(buildDashboardSearch({ kind: 'current-workspace', workspaceId: 'abc', dashboardId: 'def' })) + .toBe('?ws=abc&dash=def'); + }); + + it('builds ?st=&dash= for a session-bundle source', () => { + expect(buildDashboardSearch({ kind: 'session-bundle', token: 'tok123', dashboardId: 'def' })) + .toBe('?st=tok123&dash=def'); + }); + + it('URL-encodes values that need it', () => { + expect(buildDashboardSearch({ kind: 'current-workspace', workspaceId: 'a b', dashboardId: 'c/d' })) + .toBe('?ws=a+b&dash=c%2Fd'); + }); + + it('round-trips both kinds through parse', () => { + const sources: DashboardOpenSource[] = [ + { kind: 'current-workspace', workspaceId: 'ws-1', dashboardId: 'dash-1' }, + { kind: 'session-bundle', token: 'tok-1', dashboardId: 'dash-1' }, + ]; + for (const source of sources) { + expect(parseDashboardOpenSource(buildDashboardSearch(source))).toEqual(source); + } + }); +}); diff --git a/tests/unit/dashboard.test.ts b/tests/unit/dashboard.test.ts index 900fad1c..fbf433ad 100644 --- a/tests/unit/dashboard.test.ts +++ b/tests/unit/dashboard.test.ts @@ -1001,3 +1001,159 @@ describe('app auth handoff', () => { await expect(app.conn.receiveAuthHandoff({ opener: asWindow({ postMessage: vi.fn() }) })).resolves.toBe(false); }); }); + +// ── #288 Phase 6: open-source modes + the Dashboard header File menu ────────── +// renderDashboard now branches on `app.dashboardOpenSource`: a current-workspace +// route verifies BOTH ids against the primary (edit) or detached (view) store; a +// session-bundle route consumes the one-time handoff into a read-only view; a +// resolution failure shows not-found. See ADR-0003. + +/** Build a dashApp wired for a specific open-source mode. `detached` seeds + * `app.detachedViews.get`; `consume` overrides `app.consumeDashboardHandoff`. */ +function modeApp(opts: { + workspace?: ReturnType | null; + openSource?: TestApp['dashboardOpenSource']; + detached?: ReturnType | null; + consume?: ReturnType; + responder?: ExecResponder; +} = {}) { + const built = dashApp({ workspace: opts.workspace, responder: opts.responder }); + const app = built.app; + app.dashboardOpenSource = opts.openSource ?? null; + app.detachedViews = { get: vi.fn(async () => (opts.detached ?? null) as never), put: vi.fn(async () => {}) }; + if (opts.consume) app.consumeDashboardHandoff = opts.consume; + return { ...built, app }; +} + +const openFileMenuBtn = (root: ParentNode | null): void => { + qs(root, '.dash-file-btn').click(); +}; +const menuItems = (): string[] => + qsa(document, '.dash-file-menu .dash-fm-item').map((b) => b.textContent || ''); + +describe('renderDashboard — open-source modes (#288)', () => { + afterEach(() => { qsa(document, '.dash-file-menu, .fm-overlay').forEach((n) => n.remove()); }); + + it('current-workspace: both ids match the primary store → editable (draggable tiles, layout switcher)', async () => { + const ws = wsWith({ id: 'd', queries: [q('q1', 'SELECT 1')], tiles: [{ id: 't1', queryId: 'q1' }] }); + const { app } = modeApp({ workspace: ws, openSource: { kind: 'current-workspace', workspaceId: 'w', dashboardId: 'd' } }); + await render(app); + expect(qs(app.root, '.dash-notfound')).toBeNull(); + expect(qsa(app.root, '.dash-tile').length).toBe(1); + expect(qs(app.root, '.dash-tile').getAttribute('draggable')).toBe('true'); + expect(layoutSelect(app.root)).toBeTruthy(); + // projection: the resolved workspace is on app.state for the File menu. + expect(app.state.dashboard?.id).toBe('d'); + }); + + it('current-workspace: workspace matches but dashboard id differs → not-found, runs nothing', async () => { + const ws = wsWith({ id: 'd' }); + const { app, calls } = modeApp({ workspace: ws, openSource: { kind: 'current-workspace', workspaceId: 'w', dashboardId: 'other' } }); + await render(app); + expect(qs(app.root, '.dash-notfound')).toBeTruthy(); + expect(qs(app.root, '.dash-notfound-title')?.textContent).toContain('unavailable'); + expect(qsa(app.root, '.dash-tile').length).toBe(0); + expect(calls.length).toBe(0); + }); + + it('current-workspace: id resolves only in the detached store → read-only view (no drag, no layout switcher)', async () => { + const detached = wsWith({ id: 'd', queries: [q('q1', 'SELECT 1')], tiles: [{ id: 't1', queryId: 'q1' }] }); + const { app } = modeApp({ workspace: null, detached, openSource: { kind: 'current-workspace', workspaceId: 'w', dashboardId: 'd' } }); + await render(app); + expect(qs(app.root, '.dash-notfound')).toBeNull(); + expect(qsa(app.root, '.dash-tile').length).toBe(1); + expect(qs(app.root, '.dash-tile').getAttribute('draggable')).toBe('false'); + expect(layoutSelect(app.root)).toBeNull(); + }); + + it('session-bundle: consumes the one-time handoff into a read-only view', async () => { + const detached = wsWith({ id: 'd', queries: [q('q1', 'SELECT 1')], tiles: [{ id: 't1', queryId: 'q1' }] }); + const consume = vi.fn(async () => detached as never); + const { app } = modeApp({ openSource: { kind: 'session-bundle', token: 'tok', dashboardId: 'd' }, consume }); + await render(app); + expect(consume).toHaveBeenCalledOnce(); + expect(qs(app.root, '.dash-notfound')).toBeNull(); + expect(qs(app.root, '.dash-tile').getAttribute('draggable')).toBe('false'); + }); + + it('session-bundle: a missing/expired token → not-found, runs nothing', async () => { + const consume = vi.fn(async () => null); + const { app, calls } = modeApp({ openSource: { kind: 'session-bundle', token: 'gone', dashboardId: 'd' }, consume }); + await render(app); + expect(qs(app.root, '.dash-notfound')).toBeTruthy(); + expect(calls.length).toBe(0); + }); + + it('a bare /dashboard open (no open-source) stays the legacy editable current workspace', async () => { + const ws = wsWith({ id: 'd', queries: [q('q1', 'SELECT 1')], tiles: [{ id: 't1', queryId: 'q1' }] }); + const { app } = modeApp({ workspace: ws, openSource: null }); + await render(app); + expect(qs(app.root, '.dash-tile').getAttribute('draggable')).toBe('true'); + }); +}); + +describe('renderDashboard — Dashboard header File menu (#302)', () => { + afterEach(() => { qsa(document, '.dash-file-menu, .fm-overlay').forEach((n) => n.remove()); }); + + const editApp = () => modeApp({ + workspace: wsWith({ id: 'd', queries: [q('q1', 'SELECT 1')], tiles: [{ id: 't1', queryId: 'q1' }] }), + openSource: { kind: 'current-workspace', workspaceId: 'w', dashboardId: 'd' }, + }); + + it('edit mode: opens Export / Import / Open-for-viewing, each wired to its action; re-click + Escape close', async () => { + const { app } = editApp(); + app.actions = { ...app.actions, exportDashboard: vi.fn(), importDashboard: vi.fn(), openDashboardForViewing: vi.fn() }; + await render(app); + const btn = qs(app.root, '.dash-file-btn'); + openFileMenuBtn(app.root); + expect(btn.getAttribute('aria-expanded')).toBe('true'); + expect(menuItems()).toEqual(['Export Dashboard…', 'Import Dashboard…', 'Open for viewing…']); + // arrow-key navigation between items + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); + // click "Open for viewing…" + qsa(document, '.dash-file-menu .dash-fm-item')[2].click(); + expect(app.actions.openDashboardForViewing).toHaveBeenCalledOnce(); + expect(document.querySelector('.dash-file-menu')).toBeNull(); // closed on select + // re-open, then Escape closes + restores aria + openFileMenuBtn(app.root); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + expect(document.querySelector('.dash-file-menu')).toBeNull(); + expect(btn.getAttribute('aria-expanded')).toBe('false'); + // re-open then click the toggle again closes it + openFileMenuBtn(app.root); + btn.click(); + expect(document.querySelector('.dash-file-menu')).toBeNull(); + }); + + it('Export / Import items call their actions; overlay click closes the menu', async () => { + const { app } = editApp(); + app.actions = { ...app.actions, exportDashboard: vi.fn(), importDashboard: vi.fn(), openDashboardForViewing: vi.fn() }; + await render(app); + openFileMenuBtn(app.root); + qsa(document, '.dash-file-menu .dash-fm-item')[0].click(); + expect(app.actions.exportDashboard).toHaveBeenCalledOnce(); + openFileMenuBtn(app.root); + qsa(document, '.dash-file-menu .dash-fm-item')[1].click(); + expect(app.actions.importDashboard).toHaveBeenCalledOnce(); + openFileMenuBtn(app.root); + qs(document, '.fm-overlay').click(); + expect(document.querySelector('.dash-file-menu')).toBeNull(); + }); + + it('view mode: the File menu offers Export only (import + re-preview are edit-context)', async () => { + const detached = wsWith({ id: 'd', queries: [q('q1', 'SELECT 1')], tiles: [{ id: 't1', queryId: 'q1' }] }); + const { app } = modeApp({ workspace: null, detached, openSource: { kind: 'current-workspace', workspaceId: 'w', dashboardId: 'd' } }); + await render(app); + openFileMenuBtn(app.root); + expect(menuItems()).toEqual(['Export Dashboard…']); + }); + + it('an unrelated keydown while the menu is open is ignored', async () => { + const { app } = editApp(); + await render(app); + openFileMenuBtn(app.root); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true })); + expect(document.querySelector('.dash-file-menu')).toBeTruthy(); // still open + }); +}); diff --git a/tests/unit/file-menu.test.ts b/tests/unit/file-menu.test.ts index 2935bbf7..be482c9c 100644 --- a/tests/unit/file-menu.test.ts +++ b/tests/unit/file-menu.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; -import { libraryControls, renderLibraryTitle, openFileMenu } from '../../src/ui/file-menu.js'; +import { + libraryControls, renderLibraryTitle, openFileMenu, + triggerImportDashboard, exportDashboardAction, renderDashboardNav, +} from '../../src/ui/file-menu.js'; import { queryName } from '../../src/core/saved-query.js'; import { decodePortableBundleJson } from '../../src/dashboard/model/portable-bundle-codec.js'; import { makeApp } from '../helpers/fake-app.js'; @@ -78,36 +81,52 @@ function mount>(over: O = {} for (const node of libraryControls(app)) document.body.appendChild(node); return app; } +// #302: the File menu now only appends `importQueriesInput, replaceWorkspaceInput` +// (in that order) — the old `importDashboardInput` between them is gone. const picker = (i: number): HTMLInputElement => document.querySelectorAll('.file-menu input[type=file]')[i]; const pickFile = (input: HTMLInputElement, name = 'file.json'): void => { Object.defineProperty(input, 'files', { configurable: true, value: [{ name }] }); input.dispatchEvent(new Event('change', { bubbles: true })); }; +// #302: Import Dashboard is reachable only via `triggerImportDashboard`, which +// builds its own hidden file input and appends it to `document.body` (no menu +// involved) — pick the last file input appended to body after triggering. +const pickDashboardImport = (app: App, name = 'file.json'): void => { + triggerImportDashboard(app); + const input = [...document.querySelectorAll('input[type=file]')].pop()!; + pickFile(input, name); +}; afterEach(() => document.body.replaceChildren()); -describe('open as dashboard', () => { - it('opens the dashboard when at least one query is favorited', () => { +describe('header Dashboard nav (#302)', () => { + it('libraryControls builds a "Dashboard →" nav (hidden when there is no Dashboard) that opens the Dashboard on click', () => { const app = mount(); + const nav = app.dom.dashboardNav!; + expect(nav).toBeTruthy(); + expect(nav.classList.contains('hd-dash-nav')).toBe(true); + expect(nav.getAttribute('aria-label')).toBe('Open Dashboard'); + expect(nav.querySelector('.hd-dash-nav-label')!.textContent).toBe('Dashboard →'); + expect(nav.hidden).toBe(true); // no Dashboard by default app.actions.openDashboard = vi.fn(); - setSaved(app, [{ id: '1', name: 'Q', sql: 'SELECT 1', favorite: true }]); - openFileMenu(app); - const btn = item(/Open as dashboard/)!; - expect(btn).toBeTruthy(); - click(btn); + click(nav); expect(app.actions.openDashboard).toHaveBeenCalled(); }); - it('is disabled (with a reason) and toasts when there are no favorites', () => { + it('renderDashboardNav toggles .hidden with app.state.dashboard', () => { const app = mount(); - app.actions.openDashboard = vi.fn(); - setSaved(app, [{ id: '1', name: 'Q', sql: 'SELECT 1', favorite: false }]); - openFileMenu(app); - const btn = item(/Open as dashboard/)!; - expect(btn.textContent).toContain('no favorites'); - click(btn); - expect(app.actions.openDashboard).not.toHaveBeenCalled(); - expect(toast()).toContain('Star a query'); + app.state.dashboard = null; + renderDashboardNav(app); + expect(app.dom.dashboardNav!.hidden).toBe(true); + app.state.dashboard = dashboardDoc(); + renderDashboardNav(app); + expect(app.dom.dashboardNav!.hidden).toBe(false); + }); + + it('renderDashboardNav no-ops before libraryControls has built the slot', () => { + const app = makeApp(); + expect(app.dom.dashboardNav).toBeUndefined(); + expect(() => renderDashboardNav(app)).not.toThrow(); }); }); @@ -197,12 +216,12 @@ describe('file menu structure', () => { app.state.savedQueries = [panelQuery('s1', 'A'), panelQuery('s2', 'B')]; openFileMenu(app); expect([...document.querySelectorAll('.fm-label')].map((l) => l.textContent)).toEqual([ - 'New workspace…', 'Open as dashboard', 'Remember recent variable values', 'Clear all recent values', - 'Import queries…', 'Import Dashboard…', 'Replace workspace…', - 'Export Dashboard…', 'Export workspace…', 'Download Markdown', 'Download SQL', + 'New workspace…', 'Remember recent variable values', 'Clear all recent values', + 'Import queries…', 'Replace workspace…', + 'Export workspace…', 'Download Markdown', 'Download SQL', ]); expect([...document.querySelectorAll('.fm-section')].map((s) => s.textContent)).toEqual( - ['Dashboard', 'Variable history', 'Import / replace', 'Export', 'Share / publish']); + ['Variable history', 'Import / replace', 'Export', 'Share / publish']); expect(document.querySelector('.fm-count')!.textContent).toBe('2 queries in workspace'); openFileMenu(app); expect(document.querySelectorAll('.file-menu')).toHaveLength(1); @@ -235,22 +254,21 @@ describe('file menu structure', () => { }); describe('Export', () => { - it('Export Dashboard is disabled (with a reason) and toasts when there is no Dashboard', () => { + // #302: Export Dashboard is invoked from the Dashboard page's own File menu + // (`app.actions.exportDashboard` → `exportDashboardAction`), not the + // Workbench menu — drive it directly. + it('exportDashboardAction toasts "No dashboard to export" and does not download when there is no Dashboard', () => { const app = mount(); - openFileMenu(app); - const btn = item(/Export Dashboard/)!; - expect(btn.textContent).toContain('no dashboard'); - click(btn); + exportDashboardAction(app); expect(app.downloadFile).not.toHaveBeenCalled(); expect(toast()).toBe('No dashboard to export'); }); - it('Export Dashboard downloads a valid bundle containing only its query dependencies', () => { + it('exportDashboardAction downloads a valid bundle containing only its query dependencies', () => { const app = mount(); app.state.dashboard = dashboardDoc({ title: 'Ops', tiles: [{ id: 't1', queryId: 'p1' }] }); app.state.savedQueries = [panelQuery('p1', 'Panel'), panelQuery('unrelated', 'Unrelated')]; - openFileMenu(app); - click(item(/Export Dashboard/)!); + exportDashboardAction(app); const [fname, mime, content] = app.downloadFile.mock.calls[0]; expect(fname).toBe('Ops.json'); expect(mime).toBe('application/json'); @@ -263,7 +281,7 @@ describe('Export', () => { expect(toast()).toBe('Exported → .json'); }); - it('a role-incompatible Dashboard toasts the encode diagnostic instead of downloading', () => { + it('exportDashboardAction toasts the encode diagnostic instead of downloading for a role-incompatible Dashboard', () => { const filterQuery: SavedQueryV2 = { id: 'f1', sql: 'SELECT 1', specVersion: 1, spec: { name: 'F', dashboard: { role: 'filter' } }, }; @@ -273,8 +291,7 @@ describe('Export', () => { // re-validation catches the role mismatch. app.state.dashboard = dashboardDoc({ tiles: [{ id: 't1', queryId: 'f1' }] }); app.state.savedQueries = [filterQuery]; - openFileMenu(app); - click(item(/Export Dashboard/)!); + exportDashboardAction(app); expect(app.downloadFile).not.toHaveBeenCalled(); expect(toast()).toMatch(/^✕ /); }); @@ -443,18 +460,23 @@ describe('Import queries', () => { }); describe('Import Dashboard', () => { - it('the menu item closes the menu and opens the picker; a single-dashboard file imports directly, minting a fresh id/revision', async () => { + // #302: reachable only through `triggerImportDashboard` now (the Dashboard + // page's own File menu → `app.actions.importDashboard`) — no Workbench menu + // item, no `picker(...)` index into `.file-menu`. + it('triggerImportDashboard appends a hidden file input to the body and clicks it; a single-dashboard file imports directly, minting a fresh id/revision, and the input self-removes on change', async () => { const dep = panelQuery('p1', 'Panel'); const dash = dashboardDoc({ id: 'src-d', title: 'Sales', tiles: [{ id: 't1', queryId: 'p1' }] }); const app = mount({ FileReader: fakeReader(bundleText({ queries: [dep], dashboards: [dash] })) }); - openFileMenu(app); - const input = picker(1); - input.click = vi.fn(); - click(item(/Import Dashboard/)!); - expect(document.querySelector('.file-menu')).toBeNull(); - expect(input.click).toHaveBeenCalled(); + const clickSpy = vi.spyOn(HTMLInputElement.prototype, 'click'); + triggerImportDashboard(app); + const input = [...document.querySelectorAll('input[type=file]')].pop()!; + expect(input.parentElement).toBe(document.body); + expect(input.style.display).toBe('none'); + expect(clickSpy).toHaveBeenCalled(); + clickSpy.mockRestore(); pickFile(input); await flush(); + expect(input.isConnected).toBe(false); // self-removed once the change fired expect(document.querySelector('.fm-dialog-card')).toBeNull(); expect(app.state.dashboard).not.toBeNull(); expect(app.state.dashboard!.id).not.toBe('src-d'); // mode 'copy' mints a fresh id @@ -469,8 +491,7 @@ describe('Import Dashboard', () => { const dash = dashboardDoc({ id: 'src-d', title: 'Sales', tiles: [{ id: 't1', queryId: 'p1' }] }); const app = mount({ FileReader: fakeReader(bundleText({ queries: [dep], dashboards: [dash] })) }); app.state.dashboard = dashboardDoc({ id: 'old', title: 'My existing dashboard' }); - openFileMenu(app); - pickFile(picker(1)); + pickDashboardImport(app); const dialog = document.querySelector('.fm-dialog-card')!; expect(dialog.textContent).toContain('Import and replace current Dashboard?'); click(document.querySelector('.fm-dialog-confirm')!); @@ -484,8 +505,7 @@ describe('Import Dashboard', () => { const dash = dashboardDoc({ id: 'src-d', title: 'Sales' }); const app = mount({ FileReader: fakeReader(bundleText({ dashboards: [dash] })) }); app.state.dashboard = dashboardDoc({ id: 'old', title: 'My existing dashboard' }); - openFileMenu(app); - pickFile(picker(1)); + pickDashboardImport(app); expect(document.querySelector('.fm-dialog-card')!.textContent).toContain('Import and replace current Dashboard?'); click(document.querySelector('.fm-dialog-cancel')!); expect(app.state.dashboard!.id).toBe('old'); @@ -498,8 +518,7 @@ describe('Import Dashboard', () => { const dashA = dashboardDoc({ id: 'a', title: 'Alpha', tiles: [{ id: 't1', queryId: 'p1' }] }); const dashB = dashboardDoc({ id: 'b', title: 'Beta', tiles: [{ id: 't2', queryId: 'p2' }] }); const app = mount({ FileReader: fakeReader(bundleText({ queries: [dep1, dep2], dashboards: [dashA, dashB] })) }); - openFileMenu(app); - pickFile(picker(1)); + pickDashboardImport(app); const dialog = document.querySelector('.fm-dialog-card')!; expect(dialog.textContent).toContain('Import which dashboard?'); expect(dialog.textContent).toContain('Alpha'); @@ -516,8 +535,7 @@ describe('Import Dashboard', () => { const dashA = dashboardDoc({ id: 'a', title: 'Alpha' }); const dashB = dashboardDoc({ id: 'b', title: 'Beta' }); const app = mount({ FileReader: fakeReader(bundleText({ dashboards: [dashA, dashB] })) }); - openFileMenu(app); - pickFile(picker(1)); + pickDashboardImport(app); click(document.querySelector('.fm-dialog-cancel')!); expect(document.querySelector('.fm-dialog-card')).toBeNull(); expect(app.state.dashboard).toBeNull(); @@ -527,8 +545,7 @@ describe('Import Dashboard', () => { const dash = dashboardDoc({ id: 'src', title: 'D', tiles: [{ id: 't1', queryId: 'p1' }] }); const app = mount({ FileReader: fakeReader(bundleText({ queries: [panelQuery('p1', 'Incoming')], dashboards: [dash] })) }); app.state.savedQueries = [panelQuery('p1', 'Existing')]; // conflicting id, different content - openFileMenu(app); - pickFile(picker(1)); + pickDashboardImport(app); const rowSelect = document.querySelectorAll('.fm-select')[1]; rowSelect.value = 'skip'; rowSelect.dispatchEvent(new Event('change', { bubbles: true })); @@ -541,12 +558,33 @@ describe('Import Dashboard', () => { it('toasts and aborts when the file has no dashboard', () => { const app = mount({ FileReader: fakeReader(bundleText({ queries: [] })) }); - openFileMenu(app); - pickFile(picker(1)); + pickDashboardImport(app); expect(toast()).toBe('✕ No dashboard in file'); }); }); +describe('afterLibraryChange — dashboard route (#302)', () => { + it('on the dashboard route, a commit reloads the dashboard route and skips the Workbench repaint', async () => { + const dep = panelQuery('p1', 'Panel'); + const dash = dashboardDoc({ id: 'src-d', title: 'Sales', tiles: [{ id: 't1', queryId: 'p1' }] }); + const app = mount({ FileReader: fakeReader(bundleText({ queries: [dep], dashboards: [dash] })) }); + app.dashboardRoute = true; + app.reloadDashboardRoute = vi.fn(); + // The nav was built (hidden — no Dashboard yet) by `mount()`'s own + // `libraryControls` call; the dashboard-route branch must never re-run + // `renderDashboardNav`, even though the commit below gives the workspace + // a Dashboard. + expect(app.dom.dashboardNav!.hidden).toBe(true); + pickDashboardImport(app); + await flush(); + expect(app.reloadDashboardRoute).toHaveBeenCalled(); + expect(app.state.dashboard).not.toBeNull(); // the commit itself still landed + expect(app.updateSaveBtn).not.toHaveBeenCalled(); + expect(app.updateEditorModeUi).not.toHaveBeenCalled(); + expect(app.dom.dashboardNav!.hidden).toBe(true); // renderDashboardNav was skipped + }); +}); + describe('Replace workspace', () => { it('the menu item closes the menu, opens the picker, and — after confirming — replaces the catalog + Dashboard', async () => { const dep = panelQuery('p1', 'Panel'); @@ -554,7 +592,7 @@ describe('Replace workspace', () => { const app = mount({ FileReader: fakeReader(bundleText({ queries: [dep], dashboards: [dash] })) }); app.state.savedQueries = [panelQuery('old', 'Old')]; openFileMenu(app); - const input = picker(2); + const input = picker(1); input.click = vi.fn(); click(item(/Replace workspace/)!); expect(document.querySelector('.file-menu')).toBeNull(); @@ -575,7 +613,7 @@ describe('Replace workspace', () => { const app = mount({ FileReader: fakeReader(bundleText({ queries: [panelQuery('p1')] })) }); app.state.savedQueries = [panelQuery('old', 'Old')]; openFileMenu(app); - pickFile(picker(2)); + pickFile(picker(1)); click(document.querySelector('.fm-dialog-cancel')!); expect(document.querySelector('.fm-dialog-card')).toBeNull(); expect(app.state.savedQueries.map((q) => q.id)).toEqual(['old']); @@ -587,7 +625,7 @@ describe('Replace workspace', () => { const app = mount({ FileReader: fakeReader(bundleText({ dashboards: [dashA, dashB] })) }); app.state.dashboard = dashboardDoc({ id: 'existing', title: 'Existing' }); openFileMenu(app); - pickFile(picker(2)); + pickFile(picker(1)); const dialog = document.querySelector('.fm-dialog-card')!; expect(dialog.textContent).toContain('Replace workspace — which dashboard?'); const noneRow = [...dialog.querySelectorAll('.fm-item')].find((b) => (b.textContent || '').includes('No dashboard'))!; @@ -602,7 +640,7 @@ describe('Replace workspace', () => { const dash = dashboardDoc({ id: 'only', title: 'Only' }); const app = mount({ FileReader: fakeReader(bundleText({ dashboards: [dash] })) }); openFileMenu(app); - pickFile(picker(2)); + pickFile(picker(1)); expect(document.querySelector('.fm-dialog-card')!.textContent).toContain('Replace workspace?'); }); @@ -611,7 +649,7 @@ describe('Replace workspace', () => { app.state.dashboard = dashboardDoc({ id: 'existing', title: 'Existing' }); app.state.savedQueries = []; openFileMenu(app); - pickFile(picker(2)); + pickFile(picker(1)); expect(document.querySelector('.fm-dialog-card')!.textContent).toContain('current 0 saved queries'); click(document.querySelector('.fm-dialog-confirm')!); await flush(); diff --git a/tests/unit/handoff-token.test.ts b/tests/unit/handoff-token.test.ts new file mode 100644 index 00000000..bc1f7dae --- /dev/null +++ b/tests/unit/handoff-token.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { randomHandoffToken } from '../../src/core/handoff-token.js'; + +/** A fake `Crypto.getRandomValues` that fills a deterministic byte pattern and + * records what it was asked to fill, so tests can assert the exact call shape + * (one Uint8Array, 32 bytes long) as well as the resulting hex mapping. */ +function fakeCrypto(fill: (bytes: Uint8Array) => void): { crypto: Pick; calls: Uint8Array[] } { + const calls: Uint8Array[] = []; + return { + calls, + crypto: { + getRandomValues(array: T): T { + const bytes = array as unknown as Uint8Array; + calls.push(bytes); + fill(bytes); + return array; + }, + }, + }; +} + +describe('randomHandoffToken', () => { + it('reads exactly 32 bytes via getRandomValues', () => { + const { crypto, calls } = fakeCrypto((bytes) => bytes.fill(0)); + randomHandoffToken(crypto); + expect(calls).toHaveLength(1); + expect(calls[0]).toBeInstanceOf(Uint8Array); + expect(calls[0].length).toBe(32); + }); + + it('returns 64 lowercase hex chars', () => { + const { crypto } = fakeCrypto((bytes) => bytes.fill(0xab)); + const token = randomHandoffToken(crypto); + expect(token).toHaveLength(64); + expect(token).toMatch(/^[0-9a-f]{64}$/); + }); + + it('maps each byte to its correct hex pair, in order', () => { + const { crypto } = fakeCrypto((bytes) => { + for (let i = 0; i < bytes.length; i++) bytes[i] = i; + }); + const token = randomHandoffToken(crypto); + const expected = Array.from({ length: 32 }, (_, i) => i.toString(16).padStart(2, '0')).join(''); + expect(token).toBe(expected); + }); + + it('covers the low/high nibble boundary (0x00 and 0xff)', () => { + const { crypto } = fakeCrypto((bytes) => { + bytes[0] = 0x00; + bytes[1] = 0xff; + for (let i = 2; i < bytes.length; i++) bytes[i] = 0; + }); + const token = randomHandoffToken(crypto); + expect(token.slice(0, 4)).toBe('00ff'); + }); +}); diff --git a/tests/unit/indexeddb-detached-views-store.test.ts b/tests/unit/indexeddb-detached-views-store.test.ts new file mode 100644 index 00000000..5e8ea8bf --- /dev/null +++ b/tests/unit/indexeddb-detached-views-store.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, it } from 'vitest'; +import { createIndexedDbDetachedViewsStore } from '../../src/workspace/indexeddb-detached-views-store.js'; +import type { StoredWorkspaceV1 } from '../../src/generated/json-schema.types.js'; + +// ── A minimal in-memory fake IDBFactory ────────────────────────────────────── +// Extends the shape established in `indexeddb-workspace-store.test.ts` with +// cursor support (`openCursor`), which this adapter's retention-prune step +// needs and the original fake did not provide. Kept local to this test file. +type Handler = (() => void) | null; + +interface Cfg { + failOpen?: boolean; openError?: unknown; + failGet?: boolean; getError?: unknown; + failTx?: 'abort' | 'error' | null; txError?: unknown; + failCursor?: boolean; cursorError?: unknown; + preexistingStore?: boolean; + storeName?: string; +} + +class FakeRequest { + result: unknown; + error: unknown = null; + onsuccess: Handler = null; + onerror: Handler = null; + onupgradeneeded: Handler = null; +} + +class FakeCursorRequest extends FakeRequest {} + +class FakeObjectStore { + data: Map; + cfg: Cfg; + constructor(data: Map, cfg: Cfg) { this.data = data; this.cfg = cfg; } + + get(key: string): FakeRequest { + const req = new FakeRequest(); + queueMicrotask(() => { + if (this.cfg.failGet) { req.error = this.cfg.getError ?? null; req.onerror?.(); } else { + req.result = this.data.get(key); + req.onsuccess?.(); + } + }); + return req; + } + + put(value: unknown, key: string): FakeRequest { this.data.set(key, value); return new FakeRequest(); } + delete(key: string): FakeRequest { this.data.delete(key); return new FakeRequest(); } + + openCursor(): FakeCursorRequest { + const req = new FakeCursorRequest(); + const entries = Array.from(this.data.entries()); + let index = 0; + const step = () => { + queueMicrotask(() => { + if (this.cfg.failCursor) { req.error = this.cfg.cursorError ?? null; req.onerror?.(); return; } + if (index < entries.length) { + const [key, value] = entries[index]; + req.result = { key, value, continue: () => { index += 1; step(); } }; + } else { + req.result = null; + } + req.onsuccess?.(); + }); + }; + step(); + return req; + } +} + +class FakeTx { + error: unknown = null; + oncomplete: Handler = null; + onerror: Handler = null; + onabort: Handler = null; + db: FakeDB; + cfg: Cfg; + constructor(db: FakeDB, cfg: Cfg) { + this.db = db; + this.cfg = cfg; + // Fire completion on a macrotask, not a microtask: `put` issues an awaited + // cursor walk before attaching `transactionDone`'s `oncomplete`, so a + // microtask-scheduled completion would fire into a null handler and hang. + // A macrotask lands after the microtask queue (cursor iteration + deletes) + // fully drains — mirroring a real transaction completing after its pending + // requests settle. + setTimeout(() => { + if (this.cfg.failTx === 'abort') { this.error = this.cfg.txError ?? null; this.onabort?.(); } else if (this.cfg.failTx === 'error') { this.error = this.cfg.txError ?? null; this.onerror?.(); } else this.oncomplete?.(); + }, 0); + } + objectStore(name: string): FakeObjectStore { return new FakeObjectStore(this.db.stores.get(name)!, this.cfg); } +} + +class FakeDB { + stores = new Map>(); + objectStoreNames = { contains: (n: string) => this.stores.has(n) }; + cfg: Cfg; + constructor(cfg: Cfg) { this.cfg = cfg; } + createObjectStore(name: string): void { this.stores.set(name, new Map()); } + transaction(_names: string[], _mode: string): FakeTx { return new FakeTx(this, this.cfg); } +} + +function fakeFactory(cfg: Cfg = {}): IDBFactory { + const db = new FakeDB(cfg); + if (cfg.preexistingStore) db.stores.set(cfg.storeName ?? 'views', new Map()); + return { + open(_name: string, _version?: number) { + const req = new FakeRequest(); + queueMicrotask(() => { + if (cfg.failOpen) { req.error = cfg.openError ?? null; req.onerror?.(); return; } + req.result = db; + req.onupgradeneeded?.(); + req.onsuccess?.(); + }); + return req as unknown as IDBOpenDBRequest; + }, + } as unknown as IDBFactory; +} + +function workspace(id: string): StoredWorkspaceV1 { + return { storageVersion: 1, id, name: `Workspace ${id}`, queries: [], dashboard: null }; +} + +describe('createIndexedDbDetachedViewsStore', () => { + it('round-trips a workspace through put/get, keyed by workspace.id', async () => { + const store = createIndexedDbDetachedViewsStore(fakeFactory()); + await store.put({ workspace: workspace('w1'), savedAt: 1000 }); + expect(await store.get('w1')).toEqual(workspace('w1')); + }); + + it('returns null for a missing id', async () => { + const store = createIndexedDbDetachedViewsStore(fakeFactory()); + expect(await store.get('missing')).toBeNull(); + }); + + it('prunes to the newest maxRecords by savedAt within the same transaction', async () => { + const store = createIndexedDbDetachedViewsStore(fakeFactory(), { maxRecords: 2 }); + await store.put({ workspace: workspace('a'), savedAt: 1 }); + await store.put({ workspace: workspace('b'), savedAt: 3 }); + // Adding a third record over the cap of 2 must evict the oldest ('a'). + await store.put({ workspace: workspace('c'), savedAt: 2 }); + expect(await store.get('a')).toBeNull(); + expect(await store.get('b')).toEqual(workspace('b')); + expect(await store.get('c')).toEqual(workspace('c')); + }); + + it('does not prune when at or under the cap', async () => { + const store = createIndexedDbDetachedViewsStore(fakeFactory(), { maxRecords: 5 }); + await store.put({ workspace: workspace('a'), savedAt: 1 }); + await store.put({ workspace: workspace('b'), savedAt: 2 }); + expect(await store.get('a')).toEqual(workspace('a')); + expect(await store.get('b')).toEqual(workspace('b')); + }); + + it('honors custom db/store option names and reuses a preexisting store', async () => { + const store = createIndexedDbDetachedViewsStore( + fakeFactory({ storeName: 'custom', preexistingStore: true }), + { dbName: 'custom-db', storeName: 'custom' }, + ); + await store.put({ workspace: workspace('z'), savedAt: 5 }); + expect(await store.get('z')).toEqual(workspace('z')); + }); + + it('rejects every operation when no IndexedDB factory is available', async () => { + const store = createIndexedDbDetachedViewsStore(undefined); + await expect(store.get('x')).rejects.toThrow('IndexedDB is unavailable'); + await expect(store.put({ workspace: workspace('x'), savedAt: 1 })).rejects.toThrow('IndexedDB is unavailable'); + }); + + it('rejects when the database open fails (with and without a request error)', async () => { + const boom = createIndexedDbDetachedViewsStore(fakeFactory({ failOpen: true, openError: new Error('open boom') })); + await expect(boom.get('x')).rejects.toThrow('open boom'); + const silent = createIndexedDbDetachedViewsStore(fakeFactory({ failOpen: true })); + await expect(silent.get('x')).rejects.toThrow('IndexedDB open failed'); + }); + + it('does not poison the store when an open fails — a same-session retry can reopen', async () => { + const db = new FakeDB({}); + db.stores.set('views', new Map()); + let attempt = 0; + const factory = { + open() { + const req = new FakeRequest(); + const failThis = attempt++ === 0; + queueMicrotask(() => { + if (failThis) { req.error = new Error('transient open'); req.onerror?.(); return; } + req.result = db; + req.onsuccess?.(); + }); + return req as unknown as IDBOpenDBRequest; + }, + } as unknown as IDBFactory; + const store = createIndexedDbDetachedViewsStore(factory); + await expect(store.get('x')).rejects.toThrow('transient open'); + expect(await store.get('x')).toBeNull(); + expect(attempt).toBe(2); + }); + + it('rejects the open when it is blocked', async () => { + const factory = { + open() { + const req = new FakeRequest(); + queueMicrotask(() => { (req as unknown as { onblocked?: () => void }).onblocked?.(); }); + return req as unknown as IDBOpenDBRequest; + }, + } as unknown as IDBFactory; + const store = createIndexedDbDetachedViewsStore(factory); + await expect(store.get('x')).rejects.toThrow('IndexedDB open blocked'); + }); + + it('rejects when the get request fails (with and without a request error)', async () => { + const boom = createIndexedDbDetachedViewsStore(fakeFactory({ failGet: true, getError: new Error('get boom') })); + await expect(boom.get('x')).rejects.toThrow('get boom'); + const silent = createIndexedDbDetachedViewsStore(fakeFactory({ failGet: true })); + await expect(silent.get('x')).rejects.toThrow('IndexedDB request failed'); + }); + + it('rejects a put when the pruning cursor read fails (with and without a request error)', async () => { + const boom = createIndexedDbDetachedViewsStore(fakeFactory({ failCursor: true, cursorError: new Error('cursor boom') })); + await expect(boom.put({ workspace: workspace('x'), savedAt: 1 })).rejects.toThrow('cursor boom'); + const silent = createIndexedDbDetachedViewsStore(fakeFactory({ failCursor: true })); + await expect(silent.put({ workspace: workspace('y'), savedAt: 1 })).rejects.toThrow('IndexedDB request failed'); + }); + + it('rejects a put when the transaction aborts or errors (with and without a tx error)', async () => { + const aborted = createIndexedDbDetachedViewsStore(fakeFactory({ failTx: 'abort', txError: new Error('abort boom') })); + await expect(aborted.put({ workspace: workspace('x'), savedAt: 1 })).rejects.toThrow('abort boom'); + const erroredSilently = createIndexedDbDetachedViewsStore(fakeFactory({ failTx: 'error' })); + await expect(erroredSilently.put({ workspace: workspace('y'), savedAt: 1 })).rejects.toThrow('IndexedDB transaction failed'); + const abortedSilently = createIndexedDbDetachedViewsStore(fakeFactory({ failTx: 'abort' })); + await expect(abortedSilently.put({ workspace: workspace('z'), savedAt: 1 })).rejects.toThrow('IndexedDB transaction aborted'); + }); +}); diff --git a/tests/unit/indexeddb-handoff-store.test.ts b/tests/unit/indexeddb-handoff-store.test.ts new file mode 100644 index 00000000..f673a34d --- /dev/null +++ b/tests/unit/indexeddb-handoff-store.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it } from 'vitest'; +import { createIndexedDbHandoffStore } from '../../src/workspace/indexeddb-handoff-store.js'; +import type { HandoffRecord } from '../../src/workspace/handoff-store.types.js'; + +// ── A minimal in-memory fake IDBFactory ────────────────────────────────────── +// Same shape as `tests/unit/indexeddb-workspace-store.test.ts`'s fake (open +// with an upgrade callback, a keyed object store, readonly/readwrite +// transactions, knobs to drive every error branch) — reused here rather than +// invented from scratch. +// +// One deliberate difference: the transaction's completion event fires via a +// macrotask (`setTimeout`) instead of a microtask. `take()` issues the `get` +// and `delete` synchronously (never awaiting between them — a real transaction +// would auto-commit at the await and the paired delete would throw), then +// awaits the `get`'s *result* before attaching `transactionDone`'s `oncomplete` +// handler. That await is a microtask hop, so a microtask-scheduled completion +// event (as in the workspace-store fake, whose ops are purely synchronous +// between open and `transactionDone`) would fire and find no listener attached +// yet. A macrotask always runs after the microtask queue fully drains, so it +// reliably lands after any number of chained awaits — mirroring how a real +// IndexedDB transaction stays alive across microtask continuations while +// requests are pending. +type Handler = (() => void) | null; + +interface Cfg { + failOpen?: boolean; openError?: unknown; + failGet?: boolean; getError?: unknown; + failTx?: 'abort' | 'error' | null; txError?: unknown; + preexistingStore?: boolean; + storeName?: string; +} + +class FakeRequest { + result: unknown; + error: unknown = null; + onsuccess: Handler = null; + onerror: Handler = null; + onupgradeneeded: Handler = null; +} + +class FakeObjectStore { + data: Map; + cfg: Cfg; + constructor(data: Map, cfg: Cfg) { this.data = data; this.cfg = cfg; } + get(key: string): FakeRequest { + const req = new FakeRequest(); + // Snapshot the value at request time (like a real IndexedDB request, which + // is enqueued and processed in order): `take` issues `get` then `delete` + // synchronously, so a lazily-read value would see the post-delete state. + const snapshot = this.data.get(key); + queueMicrotask(() => { + if (this.cfg.failGet) { req.error = this.cfg.getError ?? null; req.onerror?.(); } else { + req.result = snapshot; + req.onsuccess?.(); + } + }); + return req; + } + put(value: unknown, key: string): FakeRequest { this.data.set(key, value); return new FakeRequest(); } + delete(key: string): FakeRequest { this.data.delete(key); return new FakeRequest(); } +} + +class FakeTx { + error: unknown = null; + oncomplete: Handler = null; + onerror: Handler = null; + onabort: Handler = null; + db: FakeDB; + cfg: Cfg; + constructor(db: FakeDB, cfg: Cfg) { + this.db = db; + this.cfg = cfg; + setTimeout(() => { + if (this.cfg.failTx === 'abort') { this.error = this.cfg.txError ?? null; this.onabort?.(); } else if (this.cfg.failTx === 'error') { this.error = this.cfg.txError ?? null; this.onerror?.(); } else this.oncomplete?.(); + }, 0); + } + objectStore(name: string): FakeObjectStore { return new FakeObjectStore(this.db.stores.get(name)!, this.cfg); } +} + +class FakeDB { + stores = new Map>(); + objectStoreNames = { contains: (n: string) => this.stores.has(n) }; + cfg: Cfg; + constructor(cfg: Cfg) { this.cfg = cfg; } + createObjectStore(name: string): void { this.stores.set(name, new Map()); } + transaction(_names: string[], _mode: string): FakeTx { return new FakeTx(this, this.cfg); } +} + +function fakeFactory(cfg: Cfg = {}): IDBFactory { + const db = new FakeDB(cfg); + if (cfg.preexistingStore) db.stores.set(cfg.storeName ?? 'handoff', new Map()); + return { + open(_name: string, _version?: number) { + const req = new FakeRequest(); + queueMicrotask(() => { + if (cfg.failOpen) { req.error = cfg.openError ?? null; req.onerror?.(); return; } + req.result = db; + req.onupgradeneeded?.(); + req.onsuccess?.(); + }); + return req as unknown as IDBOpenDBRequest; + }, + } as unknown as IDBFactory; +} + +function record(overrides: Partial = {}): HandoffRecord { + return { + text: '{"bundle":true}', + dashboardId: 'dash-1', + detachedWorkspaceId: 'ws-detached-1', + expiresAt: Date.now() + 60_000, + ...overrides, + }; +} + +describe('createIndexedDbHandoffStore', () => { + it('put then take returns the record, and a second take returns null (one-time consumption)', async () => { + const store = createIndexedDbHandoffStore(fakeFactory()); + const rec = record(); + await store.put('tok-1', rec); + expect(await store.take('tok-1', Date.now())).toEqual(rec); + // The record was deleted by the first take — a second take (same token + // reusing the cached open) returns null. + expect(await store.take('tok-1', Date.now())).toBeNull(); + }); + + it('returns null for an expired record and deletes it (a subsequent take is also null)', async () => { + const store = createIndexedDbHandoffStore(fakeFactory()); + const rec = record({ expiresAt: 1_000 }); + await store.put('tok-2', rec); + expect(await store.take('tok-2', 2_000)).toBeNull(); + expect(await store.take('tok-2', 2_000)).toBeNull(); + }); + + it('returns null for a missing token', async () => { + const store = createIndexedDbHandoffStore(fakeFactory()); + expect(await store.take('never-put', Date.now())).toBeNull(); + }); + + it('rejects put and take when no IndexedDB factory is available', async () => { + const store = createIndexedDbHandoffStore(undefined); + await expect(store.put('tok', record())).rejects.toThrow('IndexedDB is unavailable'); + await expect(store.take('tok', Date.now())).rejects.toThrow('IndexedDB is unavailable'); + }); + + it('rejects when the database open fails (with and without a request error)', async () => { + const boom = createIndexedDbHandoffStore(fakeFactory({ failOpen: true, openError: new Error('open boom') })); + await expect(boom.put('tok', record())).rejects.toThrow('open boom'); + const silent = createIndexedDbHandoffStore(fakeFactory({ failOpen: true })); + await expect(silent.take('tok', Date.now())).rejects.toThrow('IndexedDB open failed'); + }); + + it('rejects when the open is blocked', async () => { + const factory = { + open() { + const req = new FakeRequest(); + queueMicrotask(() => { (req as unknown as { onblocked?: () => void }).onblocked?.(); }); + return req as unknown as IDBOpenDBRequest; + }, + } as unknown as IDBFactory; + const store = createIndexedDbHandoffStore(factory); + await expect(store.take('tok', Date.now())).rejects.toThrow('IndexedDB open blocked'); + }); + + it('does not poison the store when an open fails — a same-session retry can reopen', async () => { + const db = new FakeDB({}); + db.stores.set('handoff', new Map()); + let attempt = 0; + const factory = { + open() { + const req = new FakeRequest(); + const failThis = attempt++ === 0; + queueMicrotask(() => { + if (failThis) { req.error = new Error('transient open'); req.onerror?.(); return; } + req.result = db; + req.onsuccess?.(); + }); + return req as unknown as IDBOpenDBRequest; + }, + } as unknown as IDBFactory; + const store = createIndexedDbHandoffStore(factory); + await expect(store.take('tok', Date.now())).rejects.toThrow('transient open'); + expect(await store.take('tok', Date.now())).toBeNull(); + expect(attempt).toBe(2); + }); + + it('rejects when the get request fails (with and without a request error)', async () => { + const boom = createIndexedDbHandoffStore(fakeFactory({ failGet: true, getError: new Error('get boom') })); + await expect(boom.take('tok', Date.now())).rejects.toThrow('get boom'); + const silent = createIndexedDbHandoffStore(fakeFactory({ failGet: true })); + await expect(silent.take('tok', Date.now())).rejects.toThrow('IndexedDB request failed'); + }); + + it('rejects put/take when the transaction aborts or errors (with and without a tx error)', async () => { + const aborted = createIndexedDbHandoffStore(fakeFactory({ failTx: 'abort', txError: new Error('abort boom') })); + await expect(aborted.put('tok', record())).rejects.toThrow('abort boom'); + const erroredSilently = createIndexedDbHandoffStore(fakeFactory({ failTx: 'error' })); + await expect(erroredSilently.take('tok', Date.now())).rejects.toThrow('IndexedDB transaction failed'); + const abortedSilently = createIndexedDbHandoffStore(fakeFactory({ failTx: 'abort' })); + await expect(abortedSilently.put('tok', record())).rejects.toThrow('IndexedDB transaction aborted'); + }); + + it('honors custom db/store option names and a pre-existing store (contains()===true branch)', async () => { + const store = createIndexedDbHandoffStore( + fakeFactory({ storeName: 'agg', preexistingStore: true }), + { dbName: 'custom-handoff', storeName: 'agg' }, + ); + const rec = record(); + await store.put('tok-x', rec); + expect(await store.take('tok-x', Date.now())).toEqual(rec); + }); +}); diff --git a/tests/unit/session-bundle.test.ts b/tests/unit/session-bundle.test.ts new file mode 100644 index 00000000..de4c53c1 --- /dev/null +++ b/tests/unit/session-bundle.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'vitest'; +import { + buildViewHandoffRecord, materializeDetachedWorkspace, resolveDashboardMode, +} from '../../src/dashboard/application/session-bundle.js'; +import { encodePortableBundleJson } from '../../src/dashboard/model/portable-bundle-codec.js'; +import type { + DashboardDocumentV1, SavedQueryV2, StoredWorkspaceV1, +} from '../../src/generated/json-schema.types.js'; + +const panelQuery = (id: string): SavedQueryV2 => ({ + id, sql: 'SELECT 1', specVersion: 1, + spec: { name: id, panel: { cfg: { type: 'bar', x: 0, y: [1] } } } as unknown as SavedQueryV2['spec'], +}); + +const dashboardDoc = ( + id: string, title: string, tileQueryIds: string[], +): DashboardDocumentV1 => ({ + documentVersion: 1, id, title, revision: 1, + layout: { + type: 'flow', version: 1, preset: 'full-width', + items: Object.fromEntries(tileQueryIds.map((_, index) => [`${id}-t${index}`, {}])), + }, + filters: [], + tiles: tileQueryIds.map((queryId, index) => ({ + id: `${id}-t${index}`, queryId, + })), +}); + +describe('buildViewHandoffRecord', () => { + it('snapshots the dashboard closure, encodes it, and assembles the handoff record', () => { + const dashboard = dashboardDoc('d1', 'My Dashboard', ['q1']); + const queries = [panelQuery('q1'), panelQuery('unrelated')]; + const result = buildViewHandoffRecord(dashboard, queries, { + detachedWorkspaceId: 'detached-1', expiresAt: 1000, nowISO: '2026-07-18T00:00:00.000Z', + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.record.dashboardId).toBe('d1'); + expect(result.record.detachedWorkspaceId).toBe('detached-1'); + expect(result.record.expiresAt).toBe(1000); + const parsed = JSON.parse(result.record.text); + expect(parsed.dashboards).toHaveLength(1); + expect(parsed.dashboards[0].id).toBe('d1'); + expect(parsed.queries.map((q: { id: string }) => q.id)).toEqual(['q1']); + }); + + it('returns diagnostics when encoding fails', () => { + const dashboard = dashboardDoc('d1', 'D', ['q1']); + const queries = [panelQuery('q1')]; + // An empty nowISO makes encodePortableBundleJson fail its own + // exportedAt-required check before touching schema validation. + const result = buildViewHandoffRecord(dashboard, queries, { + detachedWorkspaceId: 'detached-1', expiresAt: 1000, nowISO: '', + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.diagnostics.some((d) => d.code === 'schema-required')).toBe(true); + }); +}); + +describe('materializeDetachedWorkspace', () => { + const encodedBundle = (dashboard: DashboardDocumentV1, queries: SavedQueryV2[]): string => { + const encoded = encodePortableBundleJson({ + queries, dashboards: [dashboard], nowISO: '2026-07-18T00:00:00.000Z', + }); + if (!encoded.ok) throw new Error('fixture bundle failed to encode'); + return encoded.value; + }; + + it('returns diagnostics when the text fails to decode', () => { + const result = materializeDetachedWorkspace('{bad json', 'd1', 'detached-1'); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.diagnostics.length).toBeGreaterThan(0); + }); + + it('returns a dashboard-not-found diagnostic when the id is absent from the bundle', () => { + const dashboard = dashboardDoc('d1', 'D', ['q1']); + const text = encodedBundle(dashboard, [panelQuery('q1')]); + const result = materializeDetachedWorkspace(text, 'd-missing', 'detached-1'); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.diagnostics).toEqual([ + { path: ['dashboards'], severity: 'error', code: 'dashboard-not-found', message: 'Dashboard d-missing not found in bundle' }, + ]); + }); + + it('materializes the selected dashboard into a StoredWorkspaceV1 named after its title', () => { + const dashboard = dashboardDoc('d1', 'My Dashboard', ['q1']); + const queries = [panelQuery('q1')]; + const text = encodedBundle(dashboard, queries); + const result = materializeDetachedWorkspace(text, 'd1', 'detached-1'); + expect(result.ok).toBe(true); + if (!result.ok) return; + const workspace: StoredWorkspaceV1 = result.workspace; + expect(workspace.storageVersion).toBe(1); + expect(workspace.id).toBe('detached-1'); + expect(workspace.name).toBe('My Dashboard'); + expect(workspace.dashboard?.id).toBe('d1'); + expect(workspace.queries.map((q) => q.id)).toEqual(['q1']); + }); + + it('falls back to \'Dashboard\' when the selected dashboard has an empty title', () => { + const dashboard = dashboardDoc('d1', '', ['q1']); + const queries = [panelQuery('q1')]; + const text = encodedBundle(dashboard, queries); + const result = materializeDetachedWorkspace(text, 'd1', 'detached-1'); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.workspace.name).toBe('Dashboard'); + }); +}); + +describe('resolveDashboardMode', () => { + const workspaceFixture = (id: string, dashboardId: string | null): StoredWorkspaceV1 => ({ + storageVersion: 1, id, name: 'WS', queries: [], + dashboard: dashboardId ? dashboardDoc(dashboardId, 'D', []) : null, + }); + + it('resolves edit mode when the primary workspace and dashboard id both match', () => { + const primary = workspaceFixture('ws1', 'd1'); + const result = resolveDashboardMode({ kind: 'current-workspace', workspaceId: 'ws1', dashboardId: 'd1' }, primary, null); + expect(result).toEqual({ mode: 'edit', workspace: primary }); + }); + + it('resolves view mode when only the detached workspace and dashboard id match', () => { + const detached = workspaceFixture('ws1', 'd1'); + const result = resolveDashboardMode({ kind: 'current-workspace', workspaceId: 'ws1', dashboardId: 'd1' }, null, detached); + expect(result).toEqual({ mode: 'view', workspace: detached }); + }); + + it('prefers the primary workspace over the detached workspace when both match', () => { + const primary = workspaceFixture('ws1', 'd1'); + const detached = workspaceFixture('ws1', 'd1'); + const result = resolveDashboardMode({ kind: 'current-workspace', workspaceId: 'ws1', dashboardId: 'd1' }, primary, detached); + expect(result).toEqual({ mode: 'edit', workspace: primary }); + }); + + it('returns not-found when neither workspace is loaded', () => { + const result = resolveDashboardMode({ kind: 'current-workspace', workspaceId: 'ws1', dashboardId: 'd1' }, null, null); + expect(result).toEqual({ mode: 'not-found' }); + }); + + it('returns not-found when the workspace id matches but the dashboard id differs', () => { + const primary = workspaceFixture('ws1', 'd1'); + const result = resolveDashboardMode({ kind: 'current-workspace', workspaceId: 'ws1', dashboardId: 'd2' }, primary, null); + expect(result).toEqual({ mode: 'not-found' }); + }); + + it('returns not-found when the workspace has no dashboard at all', () => { + const primary = workspaceFixture('ws1', null); + const result = resolveDashboardMode({ kind: 'current-workspace', workspaceId: 'ws1', dashboardId: 'd1' }, primary, null); + expect(result).toEqual({ mode: 'not-found' }); + }); + + it('returns not-found when the workspace id itself differs from both candidates', () => { + const primary = workspaceFixture('ws-other', 'd1'); + const detached = workspaceFixture('ws-other-2', 'd1'); + const result = resolveDashboardMode({ kind: 'current-workspace', workspaceId: 'ws1', dashboardId: 'd1' }, primary, detached); + expect(result).toEqual({ mode: 'not-found' }); + }); +}); From d8a3ca4a0a551a46eb7c68c14423f89cdf09b68a Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Sat, 18 Jul 2026 16:46:59 +0000 Subject: [PATCH 2/2] chore(#288,#302): review fix (no orphan handoff token on blocked popup) + CHANGELOG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - openDashboardForViewing: skip the token write + handoff grant when the popup is blocked (openWindow → null), so a blocked "Open for viewing" leaves no un-consumable (up-to-10MB) IndexedDB record; toast to allow pop-ups instead. - CHANGELOG [Unreleased]: Phase 6 viewing + #302 File-menu restructure. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GLMprUnwnz4oaz3cnLRKcz --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ src/ui/app.ts | 9 +++++++-- tests/unit/app.test.ts | 8 ++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c33709f..c29502f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,23 @@ auto-generated per-PR notes; this file is the curated, human-readable history. ## [Unreleased] ### Added +- **Direct + full-screen Dashboard viewing with a one-time cross-tab handoff** + (#288, Dashboard v1 Phase 6 — the final phase of #280; also lands #302). Two + explicit viewing modes on the standalone `/dashboard` route (ADR-0003): an + **edit mode** opened from the new Workbench-header **`Dashboard →`** control + (bookmarkable `?ws=&dash=` current-workspace route that verifies BOTH the + workspace and dashboard ids — showing *Dashboard unavailable* rather than + silently opening a different one — and shares the IndexedDB workspace store so + reorder/relayout persist), and a read-only **view mode** opened from the + Dashboard page's own **File → "Open for viewing…"** that snapshots the current + dashboard into a detached copy in a **new tab**. The cross-tab *state* handoff + uses a **one-time IndexedDB token** (unguessable 256-bit token; the validated + bundle is written before the tab opens, atomically consumed exactly once, and + the token stripped from the URL via `history.replaceState`) — `sessionStorage` + alone is insufficient. The consumed handoff **materializes** into a persistent + detached-views store under its own id, so a view tab survives relogin/reload + while staying detached from later Workbench edits. The read-only viewer path + builds no Workbench/editor modules (boundary-tested). Closes #153. - **Portable open/import/export + a transactional import planner** (#287, Dashboard v1 Phase 5). The File menu's ambiguous `Append` is gone, replaced by resource-oriented **Import queries / Import Dashboard / Replace workspace** and @@ -31,6 +48,16 @@ auto-generated per-PR notes; this file is the curated, human-readable history. Library-only JSON is written. ### Changed +- **Dashboard operations moved out of the Workbench File menu** (#302). The + Workbench File menu now owns workspace + query-collection operations only (New + workspace / Import queries / Replace workspace / Export workspace / Download + Markdown+SQL / variable history). Dashboard navigation moved to a `Dashboard →` + control next to the workspace name (shown only when a Dashboard exists; opens + the standalone route in a new tab), and **Import Dashboard / Export Dashboard** + moved to the standalone Dashboard page's own resource-scoped **File** menu + (which also hosts the new **Open for viewing…**). Dashboard import still runs + the transactional planner and commits atomically; portable-bundle, schema, and + persistence semantics are unchanged. - **The `StoredWorkspaceV1` aggregate is now the single source of truth for the saved-query collection** (#287). All query CRUD (create/edit/rename/delete/star) commits the whole workspace through the atomic `WorkspaceRepository` diff --git a/src/ui/app.ts b/src/ui/app.ts index ea71ac78..ffdb84a6 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -1556,9 +1556,14 @@ export function createApp(env: CreateAppEnv = {}): App { 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); - if (child) conn.grantHandoffTo(child); + // 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 - // viewer's bounded retry covers the case where it reads before this lands. + // 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 }); }); diff --git a/tests/unit/app.test.ts b/tests/unit/app.test.ts index 1cbe8b2b..32799558 100644 --- a/tests/unit/app.test.ts +++ b/tests/unit/app.test.ts @@ -4426,6 +4426,14 @@ describe('Dashboard viewing (open-source, handoff, actions) — #288/#302', () = app.state.dashboard = null; app.openDashboardForViewing(); expect(opened.length).toBe(1); + // Blocked popup (openWindow → null) → no orphan token is written. + const put2 = vi.fn(async () => {}); + const blocked = createApp(env({ openWindow: asOpenWindow(() => null) })); + blocked.handoff = { put: put2, take: vi.fn(async () => null) }; + blocked.state.savedQueries = [vquery()] as never; + blocked.state.dashboard = vdash() as never; + blocked.openDashboardForViewing(); + expect(put2).not.toHaveBeenCalled(); }); it('openDashboardForViewing toasts on an unencodable dashboard and on a failed token write', async () => {