feat(#471): a per-tile Open in Workbench action, replacing the Dashboard Query button - #477
Merged
Merged
Conversation
…ard Query button The Dashboard toolbar's generic `< Query` button named no document — it was back-navigation occupying primary toolbar space. Every query-backed tile now carries its own `Open in Workbench` action instead, and leaving a Dashboard is either that per-tile act or ordinary history/tree navigation. Identity needs no new model. #427 already gives every panel tile a dedicated saved-query copy that exactly one member owns, `loadIntoNewTab` already dedups strictly on `savedId`, and Save already resolves by id — so the tile's own `queryId` IS the stable document origin the issue asks for: re-opening selects the existing tab, two Dashboards holding same-NAMED copies open two tabs, and Save keeps targeting the Dashboard copy rather than a same-named Library query. #464 remains only for the visible collision badges. The action is built unconditionally rather than `!readOnly`-gated like the grip and delete beside it (inspecting a tile's query is a View-mode act first), is omitted — never disabled-and-silent — for a queryless `text` panel or an unresolvable `queryId`, and joins `wireTileDrag`'s existing exclusion list so a press on it can never start a move. Two consequences worth naming: - a grafana-grid KPI tile's head is a pointer-transparent overlay that View mode kept permanently hidden, because every other control in it was edit-only. It now opts back into pointer events and reveals in View mode too. - #426 had restored the `< Query` button specifically because the mobile rules hide the sidebar AND the bottom nav for a full-bleed Dashboard, and a tile-less Dashboard has no per-tile action either. Per the owner decision on this issue, the bottom nav stops hiding itself on this surface and shows only Editor, which switches surface before selecting the panel. Full-bleed was a width claim; a bottom bar shortens the Dashboard without touching it. A flow KPI band member deliberately gets no action: it carries no tile chrome of any kind and is `display: contents`, so there is no box to anchor one to — positioning it there put the button in the Dashboard toolbar in a real browser, which happy-dom could not see. Filed separately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018mujm1kW7jDGpTEfscndcU
…ry as #475 Records the two decisions the diff makes that outlive it: the tile's `queryId` IS the per-Dashboard document origin #471 asks for (so neither this issue nor #464 needs a second identity next to `savedId`), and the mobile bottom nav's return to the Dashboard surface, which reverses part of #425's full-bleed rule by owner decision. Also fixes the `showHost` doc comment, which still claimed the mobile rules drop the bottom nav on that surface. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018mujm1kW7jDGpTEfscndcU
`@media (hover: none)` is the only thing that makes this action usable on a phone — where it is also the only route into the query behind a tile — and a 360px DESKTOP viewport still reports `hover: hover`, so the rule went unevaluated by every other spec. A screenshot at 360px showed the action absent for exactly that reason. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018mujm1kW7jDGpTEfscndcU
…ite cannot see **A press on the action was swallowed after a drag that released elsewhere.** `onUp` arms the post-drag click suppression on every completed move, but only a release back over the origin card fires the synthesized click that consumes it — its own comment called a cross-tile release "harmless". The flag therefore stayed armed, and the guard reading it is a capture-phase listener on the CARD, an ancestor of every action button: the next press on one was eaten and the user had to click twice. `clickSuppressCard = null` now runs before the action-chrome early return. The delete button had the same latent bug. Sabotage-checked: reverting the one-line move fails the new test and nothing else. **A KPI tile in Edit mode put the action mid-head, not top-right.** Its heading is `display: none` and no grip is built, so the head held exactly two flex items — and both declared `margin-left: auto`, which splits free space between them rather than pinning both right. The head now squares its own actions up (`justify-content: flex-end`, with the delete button's auto margin dropped inside that scope), and `.dash-tile-open` carries no margin of its own: every other tile has a `flex: 1` heading already doing that job. **On a touch device in Edit mode a KPI tile's action was invisible.** Every reveal rule for that overlay head is `:hover`/`:focus-within`, and in Edit mode additionally `:not(.is-view)` — a phone satisfies none of them, and ancestor opacity composites, so the button's own `opacity: 1` could not rescue it. The `hover: none` override has to sit with the KPI block: the base rule has identical specificity, so source order alone decides — the first attempt lived earlier in the file and lost silently. Also from the review: the acceptance criterion "Save updates the Dashboard copy, not a same-named Library query" is now asserted directly end to end rather than resting on the identity argument; the KPI overlay gains a keyboard test (focus reveals the head, Enter activates); `DashboardApp.showQuerySurface` is gone, unused since the back button; DESIGN.md's toolbar description no longer names a control that does not exist; and the whitespace-only-tile-title footgun in accessible names is #476. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018mujm1kW7jDGpTEfscndcU
This was referenced Jul 26, 2026
… get the action Three review findings, none of them cosmetic. **Back opened the wrong Dashboard, at the top.** `openSavedQuery` switches to the Workbench, which disposes the Dashboard surface; the URL deliberately carries no Dashboard id (#425), and with the session now saying "query", `adoptRouteMainSurface` had nothing left to consult and resolved the COMPATIBILITY Dashboard. So: open B in a workspace holding A and B, scroll it, open a tile's query, press Back — and A appeared, scrolled to the top. That was survivable while a global `< Query` button existed; #471 removes it and leans on history navigation instead, so it is now a broken requirement rather than a wart. Each Dashboard history ENTRY now carries `{workspaceKey, dashboardId, currentMember, scrollTop}` in `history.state`, written onto the entry being left (and onto a Dashboard entry as it is created). Per-entry rather than one session-wide "last Dashboard" memo, so several Back steps across several Dashboards each restore their own; invisible, so shareable URLs and #425's "the URL is derived from the session surface" both stand; and discardable, so an entry carrying none behaves exactly as before. `restoreDashboardSurface` runs it through `reconcileMainSurface`, so a deleted Dashboard lands on Query rather than retargeting, and a snapshot whose `workspaceKey` differs is rejected outright — a Dashboard id is unique per workspace, not globally. The offset rides as `pendingScrollTop`, a one-shot delivery beside `pendingFocus` and consumed with it, so no later repaint yanks a page the user has since scrolled. Restoring it needs more than one 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 a single post-mount assignment clamps silently to 0. It is retried after each publish until one sticks. happy-dom cannot see any of that (it stores whatever was assigned), so the proof is an e2e with two Dashboards and a real `goBack()`; sabotaging either half reproduces the report exactly (`Expected "sales", Received "ops"`, and offset 0). **A flow KPI band member has an action now.** Deferring it to #475 was the wrong call: `.dash-kpi-member` generates no box, but the repo already solves that by reaching through to the card — `.is-nav-target > *` and `.dash-drop-target > *` exist for this exact host. The action is anchored inside the member's first card, which leaves the drag geometry untouched (`surfaceRect`/`hitRects` derive the member's rect from those same card children) and the band's responsive sizing alone (`position: relative` is scoped to `.dash-kpi-stream`, never added to the shared `.kpi-card` base). `renderKpiInto` replaces that card on every publish, so the attachment is re-applied per repaint — and stays off the grid engine's path, which already has the action in the tile head. #475 keeps only the chrome that is still genuinely missing there: delete, grip, resize, title. **Keyboard activation could still be swallowed.** The previous fix cleared the post-drag click suppression on the next `pointerdown`, and Enter/Space on a focused button dispatches a click with no pointer event at all — so the first keyboard activation after a drag that released away from its card did nothing. The arming now disarms itself on a zero-delay timer: the synthesized click, when there is one, arrives in the same input task as the release, so it is still swallowed, while anything later meets a cleared flag. A timestamp window was tried first and rejected — a fast Tab+Enter falls inside any window wide enough to be safe. Also fixed while here, both from the same review: an Edit-mode KPI tile put the action mid-head, because its heading is `display: none` and two competing `margin-left: auto` items split the free space instead of pinning both right; and that same KPI overlay stayed invisible on touch in Edit mode, where every reveal rule is `:hover`/`:focus-within` and additionally `:not(.is-view)`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018mujm1kW7jDGpTEfscndcU
…kbench-471 # Conflicts: # CHANGELOG.md # docs/ADR-0003-dashboard-viewing.md
… into feat/tile-open-in-workbench-471 # Conflicts: # src/ui/dashboard.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
Closes #471.
The Dashboard toolbar's
< Querybutton named no document. It was ordinaryback-navigation occupying primary toolbar space, and after using it the user still
had to find the tile's query in the Workbench themselves. Every query-backed tile
now carries its own Open in Workbench action instead, in its own chrome, in both
View and Edit mode and in both layout engines.
Identity needed no new model, and that is the main finding of this change. #471
asks for tab identity by "stable document origin, such as
dashboardId + dashboardQueryId", and #464 sketches aQueryTabOriginunion. Neither is required:#427 already made every panel tile reference a dedicated saved-query copy that
exactly one member owns, and a tile's
queryIdis explicitly not patchable(
DashboardTilePatch: "tile identity is stable").loadIntoNewTabhas alwaysdeduplicated on
savedId, andcommitSavedQueryresolves its write target by id —so re-opening selects the existing tab, two Dashboards holding same-named
copies open two tabs, and Save targets the Dashboard copy, not a same-named
Library query, all hold by construction. Adding a parallel origin field would have
been the second source of truth #427 refused. #464 is left owning the visible
half only (collision badges, origin tooltips) — reconciled in a comment there and in
roadmap #68.
The action is deliberately not
!readOnly-gated like the grip and delete besideit: inspecting the query behind a tile is a View-mode act first. It is subtle until
hover/focus, always visible on touch, and joins
wireTileDrag's existing exclusionlist so pressing it can never start a move. A queryless
textpanel or anunresolvable
queryIdrenders no action — not a disabled one, and never onepointing at another tile's document.
Two consequences worth reviewing closely
rules were scoped
:not(.is-view)— correct while every control in it wasedit-only. It now reveals in View mode and the action opts back into pointer
events. The
::beforehover outline stays edit-only (it advertises drag/resize).issue, reversing part of Add dashboard selection state and a full-size dashboard work surface #425). Add a Databases/Dashboards switcher and dashboard hierarchy tree #426 had restored the
< Querybutton preciselybecause the mobile rules hide the sidebar and the nav for a full-bleed
Dashboard — and a per-tile action cannot rescue a Dashboard with no tiles.
Only Editor shows (the other two panel values still say nothing about a
Dashboard), it switches surface before selecting the panel, and it is not painted
as the active tab. Full-bleed was a width claim; a bottom bar shortens the
Dashboard without overlapping it (asserted).
Two bugs only a real browser could catch
Both are recorded in the ADR addendum because they are the kind of thing that gets
reintroduced:
Dashboard toolbar.
.dash-kpi-memberisdisplay: contents, so it has no box atall — which is also why the drag code derives its rect from its children. It is now
anchored INSIDE the member's first card, the reach-through
.is-nav-target > *and.dash-drop-target > *already use for that host, so the drag geometry and theband's responsive sizing are both untouched.
@media (hover: none)— the only thing making the action usable on a phone,where it is also the primary route into a tile's query — was evaluated by no test,
because a 360px desktop viewport still reports
hover: hover. Now covered underreal mobile emulation.
Tests
tests/unit/dashboard.test.ts— a new#471block: present in both modes and bothengines, exact
queryIdper tile (two same-named copies → two ids), omitted for atextpanel and for a dangling id, aria/title/<button>semantics, the drag guardagainst the real button, the grid KPI overlay head in both modes, and the flow-KPI
boundary. The three back-button tests are re-fixtured to assert absence, including
the Add a Databases/Dashboards switcher and dashboard hierarchy tree #426 regression guard, which is restated rather than deleted: it now asserts
the route moved (per-tile action + bottom nav) instead of vanishing.
tests/unit/app.test.ts— the bottom-nav Editor entry on the Dashboard surfaceswitches surface, and on the Query surface still only switches panels.
tests/e2e/tile-open-workbench.{html,spec.js}— new, mounts the whole app likevariable-tab.html: hover/focus reveal, Enter activation, the KPI overlay in Viewmode,
pointer-events, a modifier-drag from the action moving nothing, twosame-named copies → two tabs → reopen selects, the text tile, the phone nav, and
the touch case, the KPI overlay by keyboard, an Edit-mode KPI tile's action
GEOMETRY, and Save writing the Dashboard's copy while its same-named sibling stays
untouched. 12 specs, run on chromium + webkit (269 e2e passing in total).
tests/e2e/dashboard-mobile.spec.js— the#425nav assertion re-fixtured to thenew contract, plus a new assertion that the nav shortens rather than overlaps the
Dashboard.
Found by the review pass (all fixed here)
Three defects, two of which no unit test could have seen:
card.
onUparms the post-drag click suppression on every completed move, butonly a release back over the origin card fires the click that consumes it — its
own comment called the cross-tile case "harmless". The guard reading the flag is a
capture-phase listener on the card, an ancestor of every action button, so the
next press was eaten. The delete button had the same latent bug. Sabotage-checked.
display: noneand no grip is built, so the head held two flex items both declaring
margin-left: auto— which splits free space rather than pinning both right.for that overlay is
:hover/:focus-withinplus:not(.is-view); a phonesatisfies none, and ancestor opacity composites. The
hover: noneoverride alsohad to move next to the KPI block — the base rule has identical specificity, so
source order alone decides, and the first placement lost silently.
Also from the review: "Save updates the Dashboard copy, not a same-named Library
query" is now asserted directly end to end rather than resting on the identity
argument; the KPI overlay gained a keyboard test;
DashboardApp.showQuerySurfaceisgone (unused since the back button); and
DESIGN.mdno longer describes a toolbarcontrol that does not exist.
Known and tracked, not fixed here
carry the action. What remains there is the band's other missing chrome — no
delete, grip, resize or title. A destructive control deserves a deliberate
placement rather than "wherever the first card has room".
inbox) — a whitespace-onlytile.titlesurvives into accessible names.Unreachable through shipped UI today; reachable the moment a rename-tile affordance
ships. Fixing it changes title semantics for every consumer, so it wants its own
change.
openSavedQueryswitches surface before resolving the id. Reachablefrom this caller only inside the async window of a cross-tab delete, and the symptom
is a surface switch with no new tab, not an empty Workbench.
Second review round (owner findings, all fixed here)
Three more, filed against the first push:
P1 — Back returned the wrong Dashboard, at the top.
openSavedQueryswitchesto the Workbench, which disposes the Dashboard surface; the URL carries no Dashboard
id by design (Add dashboard selection state and a full-size dashboard work surface #425), and with the session saying "query"
adoptRouteMainSurfacefellback to the COMPATIBILITY Dashboard. Open B in a workspace holding A and B, scroll,
open a tile's query, press Back → A, at the top. Tolerable while a global
< Querybutton existed; a broken requirement once this PR removes it and leans on history.
Each Dashboard history entry now carries
{workspaceKey, dashboardId, currentMember, scrollTop}inhistory.state— per-entry, so multiple Back stepsacross multiple Dashboards each restore their own; invisible, so shareable URLs and
Add dashboard selection state and a full-size dashboard work surface #425's URL contract both stand; validated through
reconcileMainSurface, so adeleted Dashboard lands on Query rather than retargeting, and a foreign
workspaceKeyis rejected (ids are unique per workspace, not globally). The offsetis a one-shot
pendingScrollTopbesidependingFocus, re-applied after each publishuntil it sticks — a single post-mount write clamps to 0, because tiles (and
grafana-grid's px heights) only arrive with the first publish.
P1 — flow KPI tiles now have the action (see above); Flow layout's KPI band members have no delete, grip, resize or title chrome #475 rescoped.
P2 — keyboard activation could still be swallowed. Clearing the post-drag click
suppression on the next
pointerdownmissed Enter/Space, which dispatches a clickwith no pointer event. The arming now disarms itself on a zero-delay timer: the
synthesized click shares the release's input task and is still swallowed, anything
later is not. (A timestamp window was tried and rejected — a fast Tab+Enter falls
inside any window wide enough to be safe.)
Each is sabotage-verified. Reverting the history stamp reproduces the report exactly —
Expected "sales", Received "ops"— and reverting the timer fails the keyboard testalone.
Checklist
npm testpasses (the per-file coverage gate is non-negotiable) — 5631 tests;pretestcoverscheck:schemas,check:arch,check:typesnpm run buildsucceeds (single-filedist/sql.html)src/core/, network insrc/net/(injected fetch), DOM insrc/ui/— the queryless decision reuses the sharedcore/panel-cfg.tspredicate; the newDashboardAppmember is the existingApp['openSavedQuery']seamCHANGELOG.md([Unreleased]) updated if behavior or the deployed surface changed — CHANGELOG done; the deployed surface (deploy/http_handlers.xml) is unchanged🤖 Generated with Claude Code
https://claude.ai/code/session_018mujm1kW7jDGpTEfscndcU