Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,45 @@ auto-generated per-PR notes; this file is the curated, human-readable history.
an `Array(scalar T)` nobody has configured.)

### Changed
- **A Dashboard tile opens its own query in the Workbench; the Dashboard-level
`< Query` button is gone** (#471). Every query-backed tile carries an
**Open in Workbench** icon action in its own chrome, in both View and Edit mode
and in both layout engines. It opens *that tile's* query document in the main SQL
editor — or selects the tab already showing it — and leaves the Dashboard's
filters, layout, scroll position and View/Edit state untouched.

Identity is the tile's own query id, never the displayed name. A Dashboard tile
references a dedicated saved-query copy that exactly one member owns (#427), so
two Dashboards holding same-**named** copies open two separate tabs, re-opening
one selects the tab it already has, and **Save from that tab updates the
Dashboard's copy** rather than a same-named Library query. The compact
collision *badges* that make two same-named tabs tellable apart remain #464.

The action is deliberately not edit-mode-only — inspecting the query behind a
tile is a View-mode act first. It is subtle until the tile is hovered or the
button is focused, always visible on touch, and pressing it never starts a tile
drag, resize, selection or chart gesture. A **queryless** tile (a `text` panel)
and a tile whose query id no longer resolves show no action at all, rather than a
disabled control or one pointing at an unrelated document.

The removed `< Query` button named no document — it was ordinary back-navigation
occupying primary toolbar space. Two follow-on consequences: a Grid-Tiles **KPI**
tile's chrome overlay now reveals and accepts clicks in View mode too (it had no
View-mode control before), and because the mobile rules hide the sidebar for a
full-bleed Dashboard, **the bottom nav no longer hides itself on the Dashboard
surface** — it shows only **Editor**, which returns to the Workbench. Without
that, a phone looking at a Dashboard with no tiles would have had no route back
at all.

**Returning with Back now lands on the Dashboard you left, where you left it.**
The URL deliberately carries no Dashboard id, and opening a query tears the
Dashboard down, so Back used to rebuild the *first* Dashboard in the workspace at
the top of the page — tolerable while a global back button existed, and not
tolerable once per-tile actions are the way out. Each Dashboard history entry now
remembers its own id, current member and scroll offset, so several Back steps
across several Dashboards each return correctly; a remembered Dashboard that has
since been deleted falls back to the Workbench rather than to some other one.

- **The Dashboard runtime now says "variable" everywhere it meant "variable"**
(#459). #447 replaced curated Dashboard filters with inferred variables but left
the surviving code calling them filters, so the source read as if a model it had
Expand Down
5 changes: 3 additions & 2 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -387,8 +387,9 @@ asserted rather than reviewed.
- **Error / Disabled:** semantic text/background/border tokens for errors; opacity and cursor changes for disabled controls.

### Navigation
- Table, JSON, panel types, query tabs, schema/library modes, and dashboard layout choices share one quiet tab/segmented-control vocabulary. Inactive items use muted text on transparent or subtle surfaces; active items gain ink, a tonal surface, a structural border, or the accent. Mobile replaces the split workspace with a bottom navigation for Tables, Editor, and Results at `768px` and below.
- The Dashboard surface toolbar (`[Back to query] · title · [View | Edit]`) is the **same** toolbar as the filter row stacked beneath it — one gap, one height, one padding — so the two read as one sticky top bar rather than two competing bands. Only the title differs, and only by weight.
- Table, JSON, panel types, query tabs, schema/library modes, and dashboard layout choices share one quiet tab/segmented-control vocabulary. Inactive items use muted text on transparent or subtle surfaces; active items gain ink, a tonal surface, a structural border, or the accent. Mobile replaces the split workspace with a bottom navigation for Tables, Editor, and Results at `768px` and below; on the Dashboard surface that bar keeps only **Editor**, as the route back to the Workbench.
- The Dashboard surface toolbar (`[layout · tile count · search · filters] … [freshness] · [View | Edit]`) is the **same** toolbar as the filter row stacked beneath it — one gap, one height, one padding — so the two read as one sticky top bar rather than two competing bands.
- Leaving a Dashboard is a **per-tile** act, not a global one: each query-backed tile carries a quiet `Open in Workbench` icon in its own chrome, subtle until the tile is hovered or the icon focused, always shown where there is no hover. There is no Dashboard-level back button — an action that names its document beats generic back-navigation holding primary toolbar space.

### Data Table
- Sticky headers and row numbers preserve context during two-axis scrolling.
Expand Down
98 changes: 98 additions & 0 deletions docs/ADR-0003-dashboard-viewing.md
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,104 @@ unchanged. Three decisions are worth recording.
one would have made the inconsistency worse, not smaller — the Dashboards tree
has said "Variables" since #447 phase 1.

## Addendum (#471, 2026-07-26): leaving a Dashboard is a per-tile act

The `Back to query` control the #426 addendum above restored is removed. It named
no document: generic back-navigation in the primary toolbar, which left the user to
find the corresponding query in the Workbench themselves. Every query-backed tile
carries its own `Open in Workbench` action instead. Four decisions are worth
recording.

- **The tile's `queryId` IS the provenance the feature needs — no new tab model.**
#471 asks for tab identity by "stable document origin, such as `dashboardId +
dashboardQueryId`", and #464 proposes a `QueryTabOrigin` union. Neither is
required here, because the #427 addendum above already made ownership a
reference: a panel tile points at a dedicated saved-query copy that exactly one
member owns, so that copy's id already IS a per-Dashboard document identity.
`loadIntoNewTab` has always deduplicated on `savedId` and `commitSavedQuery`
resolves its write target by id, so "re-opening selects the existing tab", "two
same-named copies are two tabs" and "Save targets the Dashboard copy" all hold by
construction. What #464 still owns is the *visible* half — the collision badges
and full-origin tooltips that make two identically-named tabs tellable apart. A
`TabDocument` arm for this would have been a second, redundant identity next to
`savedId`, which is exactly the "second source of truth" #427 refused.

- **The action is not edit-mode chrome.** The grip, delete and resize handle are all
`!readOnly`-gated and built only in Edit mode. This one is built unconditionally,
like the heading: inspecting the query behind a tile is a View-mode act first, and
the issue requires both modes. That had one consequence nothing else forced — a
Grid-Tiles KPI tile's head is an absolutely-positioned, `pointer-events: none`
overlay whose reveal rules were scoped `:not(.is-view)`, correct while every
control inside it was edit-only. View mode now reveals it and the action opts back
into pointer events.

- **Nothing to open means no control, not a disabled one.** A `text` panel is
queryless by capability (`isQuerylessPanel`, the same predicate Save and share
already use) and an unresolvable `queryId` belongs to a tile already rendering its
own missing-query error. Both render no action. A disabled button would have
advertised an affordance that can never work, and pointing it at the Dashboard's
first query would have opened someone else's document.

- **A flow KPI band member reaches its action through the card, not the host.** Flow
renders a KPI tile into a `.dash-kpi-member` host that carries no tile chrome of any
kind — no head, no delete, no grip, no resize — and is `display: contents`, so it
generates no box at all; that is also why the drag code derives its rect from the
host's children. Absolutely positioning the action against it put the button in the
Dashboard toolbar in a real browser, which happy-dom could not see. It is therefore
anchored INSIDE the member's first card, the same reach-through the `.is-nav-target`
ring and the `.dash-drop-target` outline already need for this host — which leaves
the drag geometry untouched, because the button sits inside one of the very child
boxes those rects are derived from. `renderKpiInto` replaces that card on every
publish, so the attachment is re-applied with each repaint rather than once.
(Giving the band a full chrome surface — it still has no delete affordance — remains
#475.)

**Back is now a supported way home, which needed a per-entry memory.** #471's
acceptance criteria lean on ordinary history navigation precisely because the global
control is gone — and that exposed a hole #425 had left tolerable: the URL carries no
Dashboard id (by design, above), the Dashboard DOM is disposed when the Workbench
takes the work area, and `adoptRouteMainSurface` had nothing to consult once the
session said "query". It resolved the *compatibility* Dashboard, so Back out of a tile
opened the collection's FIRST Dashboard, at the top of the page, however many
Dashboards the user had moved through.

The fix keeps the URL exactly as it was and puts the missing facts in
`history.state` instead — `{dash: {workspaceKey, dashboardId, currentMember,
scrollTop}}`, written onto the entry being LEFT (and onto a Dashboard entry as it is
created). Three properties made that the right home rather than a session-wide "last
Dashboard" memo:

- it is **per entry**, so several Back steps across several Dashboards each restore
their own — a single memo can only ever be right about the most recent one;
- it is **invisible**, so shareable URLs are untouched and #425's "the URL is derived
from the session surface, never the other way round" still holds;
- it is **discardable**: an entry that carries none (a fresh load, or one written
before this existed) simply falls back to the old behaviour.

It is validated like any other selection — `restoreDashboardSurface` runs the snapshot
through `reconcileMainSurface`, so a remembered Dashboard that has since been deleted
lands on **Query** rather than retargeting to a different one, and the snapshot is
rejected outright when its `workspaceKey` does not match (a Dashboard id is unique per
workspace, not globally — the #457 addendum's rule).

The offset rides in `MainSurfaceState` as `pendingScrollTop`, a second one-shot
delivery beside `pendingFocus` and consumed with it, so no later repaint can yank a
page the user has since scrolled. Applying it is not a single write: at mount the grid
host is still empty (tiles arrive with the first publish, and grafana-grid's per-tile
px heights with them), so an offset written then clamps silently to `0`. It is
re-attempted after each publish until one sticks — which happy-dom cannot observe at
all, since it stores whatever was assigned.

The mobile consequence is recorded here too, because it reverses part of the #425
addendum. #426 had restored the back button specifically because the mobile rules
drop the sidebar *and* the bottom nav for a full-bleed Dashboard, and a per-tile
action cannot rescue a Dashboard with no tiles. Per the owner decision on #471, the
bottom nav stops hiding itself on this surface and shows only **Editor** — the other
two panel values still say nothing about a Dashboard — and pressing it switches
surface before selecting the panel, the same order `openSavedQuery` and
`openVariableTab` use. Full-bleed was a *width* claim; a bottom bar shortens the
Dashboard without overlapping it.

## Alternatives considered

- **Durable detached snapshots:** rejected because they silently diverge from
Expand Down
111 changes: 109 additions & 2 deletions src/application/main-surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,82 @@ export type MainSurfaceState =
mode: DashboardSurfaceMode;
currentMember: DashboardFocusTarget | null;
pendingFocus: DashboardFocusTarget | null;
/**
* #471 — a scroll offset still owed to the surface, consumed exactly once
* alongside `pendingFocus`.
*
* A second delivery field rather than part of `pendingFocus` because it has a
* different origin: focus comes from a navigation REQUEST, this comes from
* history. Opening a tile's query tears the Dashboard DOM down (the Workbench
* disposes it), so Back rebuilds it from scratch — and #471 removed the global
* control that used to make that round trip avoidable, which is what turns
* "scroll is lost" from a wart into a broken requirement. `null` means "start
* where a fresh render starts", i.e. the top.
*/
pendingScrollTop: number | null;
};

/** The one application-level Dashboard navigation request (#425). */
export interface OpenDashboardRequest {
dashboardId: string;
mode: DashboardSurfaceMode;
focus?: DashboardFocusTarget;
/** #471: only history restoration supplies this — see `restoreDashboardSurface`. */
scrollTop?: number;
}

/**
* What a Dashboard history entry has to remember (#471).
*
* Everything here is absent from the URL by design: #425 keeps the selected Dashboard
* id, the current member and the scroll offset as session state, so a history entry
* that records none of them cannot be returned to faithfully. Held in
* `history.state`, which is per-entry and invisible to the user, so multiple
* Back steps across several Dashboards each restore their own.
*/
export interface DashboardHistorySnapshot {
workspaceKey: string | null;
dashboardId: string;
currentMember: DashboardFocusTarget | null;
scrollTop: number;
}

/** Read a snapshot back out of an opaque `history.state`, or `null` when the entry
* carries none (a fresh load, an entry written before this existed) or when it
* belongs to a DIFFERENT workspace — a Dashboard id is unique per workspace, not
* globally, so a coincidental match must not resolve. */
export function readDashboardHistorySnapshot(
state: unknown, workspaceKey: string | null,
): DashboardHistorySnapshot | null {
if (typeof state !== 'object' || state === null) return null;
const dash = (state as { dash?: unknown }).dash;
if (typeof dash !== 'object' || dash === null) return null;
const candidate = dash as Partial<DashboardHistorySnapshot>;
if (typeof candidate.dashboardId !== 'string' || candidate.dashboardId === '') return null;
if ((candidate.workspaceKey ?? null) !== workspaceKey) return null;
return {
workspaceKey,
dashboardId: candidate.dashboardId,
currentMember: candidate.currentMember ?? null,
scrollTop: typeof candidate.scrollTop === 'number' && Number.isFinite(candidate.scrollTop)
? candidate.scrollTop
: 0,
};
}

/** The snapshot for the surface currently on screen, or `null` in Query mode —
* written onto the history entry being LEFT, with the live scroll offset the DOM
* has right now. */
export function dashboardHistorySnapshot(
surface: MainSurfaceState, workspaceKey: string | null, scrollTop: number,
): DashboardHistorySnapshot | null {
if (surface.kind !== 'dashboard') return null;
return {
workspaceKey,
dashboardId: surface.dashboardId,
currentMember: surface.currentMember,
scrollTop,
};
}

/** The Query surface carries no parameters, so one frozen value serves every
Expand Down Expand Up @@ -109,10 +178,44 @@ export function resolveOpenDashboard(
mode: request.mode,
currentMember: presentMember(lookup.dashboard, requested),
pendingFocus: requested,
// #471: an OPEN request never restores a scroll offset — only history does
// (`restoreDashboardSurface`). Opening a Dashboard deliberately starts at the
// top; carrying a stale offset here would land the user mid-page for a
// Dashboard they navigated to fresh.
pendingScrollTop: request.scrollTop ?? null,
},
};
}

/**
* Rebuild a Dashboard selection from a history entry (#471).
*
* The URL carries no Dashboard id (#425 keeps it session state), so returning to a
* Dashboard entry with Back had nothing to resolve and fell back to the collection's
* FIRST Dashboard — the wrong document whenever the user had selected another. The
* snapshot rides in `history.state` instead of the URL, which keeps the shareable URL
* unchanged and makes the memory per-history-entry rather than one global "last
* Dashboard".
*
* Validated like any other selection: a snapshot whose Dashboard is gone (or
* ambiguous) falls back to Query mode rather than retargeting, and a stale member
* reference drops itself.
*/
export function restoreDashboardSurface(
snapshot: DashboardHistorySnapshot, mode: DashboardSurfaceMode, workspace: WorkspaceDashboards | null,
): MainSurfaceState {
return reconcileMainSurface({
kind: 'dashboard',
dashboardId: snapshot.dashboardId,
// The MODE comes from the route, not the snapshot: the URL does carry it, and it
// is the authority for what the entry describes.
mode,
currentMember: snapshot.currentMember,
pendingFocus: null,
pendingScrollTop: snapshot.scrollTop,
}, workspace);
}

/**
* Re-validate a selection against committed truth. A selected Dashboard that
* was removed — or whose id became ambiguous — falls back to **Query** mode
Expand Down Expand Up @@ -205,8 +308,12 @@ export function isSameDashboardSelection(
* marking the member the user navigated to after its one-shot delivery is
* spent. */
export function withoutPendingFocus(surface: MainSurfaceState): MainSurfaceState {
if (surface.kind !== 'dashboard' || surface.pendingFocus === null) return surface;
return { ...surface, pendingFocus: null };
if (surface.kind !== 'dashboard') return surface;
// #471: `pendingScrollTop` is handed to the same render pass and consumed with it,
// for the same reason — a later repaint must not yank the page back to an offset
// the user has since scrolled away from.
if (surface.pendingFocus === null && surface.pendingScrollTop === null) return surface;
return { ...surface, pendingFocus: null, pendingScrollTop: null };
}

/**
Expand Down
Loading
Loading