Skip to content

feat(#471): a per-tile Open in Workbench action, replacing the Dashboard Query button - #477

Merged
BorisTyshkevich merged 7 commits into
mainfrom
feat/tile-open-in-workbench-471
Jul 26, 2026
Merged

feat(#471): a per-tile Open in Workbench action, replacing the Dashboard Query button#477
BorisTyshkevich merged 7 commits into
mainfrom
feat/tile-open-in-workbench-471

Conversation

@BorisTyshkevich

@BorisTyshkevich BorisTyshkevich commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

What & why

Closes #471.

The Dashboard toolbar's < Query button named no document. It was ordinary
back-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 a QueryTabOrigin union. Neither is required:
#427 already made every panel tile reference a dedicated saved-query copy that
exactly one member owns, and a tile's queryId is explicitly not patchable
(DashboardTilePatch: "tile identity is stable"). loadIntoNewTab has always
deduplicated on savedId, and commitSavedQuery resolves 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 beside
it: 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 exclusion
list so pressing it can never start a move. A queryless text panel or an
unresolvable queryId renders no action — not a disabled one, and never one
pointing at another tile's document.

Two consequences worth reviewing closely

  • A Grid-Tiles KPI tile's head is a pointer-transparent overlay whose reveal
    rules were scoped :not(.is-view) — correct while every control in it was
    edit-only. It now reveals in View mode and the action opts back into pointer
    events. The ::before hover outline stays edit-only (it advertises drag/resize).
  • The mobile bottom nav returns to the Dashboard surface (owner decision on this
    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 < Query button precisely
    because 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:

  1. Positioning the action against a flow KPI band member put the button in the
    Dashboard toolbar
    . .dash-kpi-member is display: contents, so it has no box at
    all — 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 the
    band's responsive sizing are both untouched.
  2. @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 under
    real mobile emulation.

Tests

  • tests/unit/dashboard.test.ts — a new #471 block: present in both modes and both
    engines, exact queryId per tile (two same-named copies → two ids), omitted for a
    text panel and for a dangling id, aria/title/<button> semantics, the drag guard
    against 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 surface
    switches surface, and on the Query surface still only switches panels.
  • tests/e2e/tile-open-workbench.{html,spec.js} — new, mounts the whole app like
    variable-tab.html: hover/focus reveal, Enter activation, the KPI overlay in View
    mode, pointer-events, a modifier-drag from the action moving nothing, two
    same-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 #425 nav assertion re-fixtured to the
    new 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:

  1. A press on the action was swallowed after a drag that released away from its
    card.
    onUp arms the post-drag click suppression on every completed move, but
    only 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.
  2. An Edit-mode KPI tile put the action mid-head. Its heading is display: none
    and 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.
  3. On touch, in Edit mode, a KPI tile's action was invisible. Every reveal rule
    for that overlay is :hover/:focus-within plus :not(.is-view); a phone
    satisfies none, and ancestor opacity composites. The hover: none override also
    had 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.showQuerySurface is
gone (unused since the back button); and DESIGN.md no longer describes a toolbar
control that does not exist.

Known and tracked, not fixed here

Second review round (owner findings, all fixed here)

Three more, filed against the first push:

  1. P1 — Back returned the wrong Dashboard, at the top. openSavedQuery switches
    to 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" adoptRouteMainSurface fell
    back 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 < Query
    button existed; a broken requirement once this PR removes it and leans on history.

    Each Dashboard history entry now carries {workspaceKey, dashboardId, currentMember, scrollTop} in history.state — per-entry, so multiple Back steps
    across 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 a
    deleted Dashboard lands on Query rather than retargeting, and a foreign
    workspaceKey is rejected (ids are unique per workspace, not globally). The offset
    is a one-shot pendingScrollTop beside pendingFocus, re-applied after each publish
    until it sticks — a single post-mount write clamps to 0, because tiles (and
    grafana-grid's px heights) only arrive with the first publish.

  2. 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.

  3. P2 — keyboard activation could still be swallowed. Clearing the post-drag click
    suppression on the next pointerdown missed Enter/Space, which dispatches a click
    with 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 test
alone.

Checklist

🤖 Generated with Claude Code

https://claude.ai/code/session_018mujm1kW7jDGpTEfscndcU

BorisTyshkevich and others added 4 commits July 26, 2026 18:04
…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
BorisTyshkevich and others added 3 commits July 26, 2026 19:59
… 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
@BorisTyshkevich
BorisTyshkevich merged commit 3296390 into main Jul 26, 2026
7 checks passed
@BorisTyshkevich BorisTyshkevich mentioned this pull request Jul 26, 2026
93 tasks
@BorisTyshkevich
BorisTyshkevich deleted the feat/tile-open-in-workbench-471 branch August 6, 2026 15:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Replace the Dashboard-level Query button with per-tile “Open in Workbench” actions

1 participant