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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ auto-generated per-PR notes; this file is the curated, human-readable history.

## [Unreleased]

### Added
- **Every Library query now has a keyboard-accessible “Add to dashboard…”
action** (#483). Its Dashboard → panel chooser uses the same assignment
command as drag/drop, creates the same independent owned query and tile, and
supports arrow navigation, Escape, Cancel, and focus restoration. The compact
Plus appears on row hover or keyboard focus; a successful add switches the
sidebar to the Dashboard tree and selects the new panel. Both chooser stages
flip above a row near the viewport bottom, and oversized menus scroll within
the viewport instead of placing Add/Cancel off-screen.

### Removed
- **The unused saved-query repair planner has been removed** (#429 phase 6 /
#500). Direct, ownership-safe Panel and Dashboard trash actions are the
Expand Down
12 changes: 7 additions & 5 deletions docs/ADR-0003-dashboard-viewing.md
Original file line number Diff line number Diff line change
Expand Up @@ -766,11 +766,13 @@ action wired to `discardVariableDraft` — the explicit, user-invoked counterpar
automatically.

The keyboard-accessible **Add to dashboard…** command (#428 acceptance bullet 9)
is deferred to **#483** by owner decision; this addendum covers the drag path
only. Mobile is out of scope for the gesture as a whole, so no `isMobile`
branches were added — touch drag is a stated non-goal, and `draggable` stays
unconditional on Library and History rows so the shipped editor drop is unchanged
there.
landed separately in **#483**. Each Library row now opens a named, two-stage
Dashboard → panel chooser that calls the same `assignLibraryQueryToPanel`
application command as drag/drop; it does not grow a second mutation path or a
target-panel/configuration picker. The original drag addendum remains pointer
specific: mobile is out of scope for the gesture as a whole, no `isMobile`
branches were added, and `draggable` stays unconditional on Library and History
rows so the shipped editor drop is unchanged there.

## Addendum (#465, 2026-07-27): Test's shape check is re-hosted on Run, not re-invented

Expand Down
10 changes: 7 additions & 3 deletions src/application/library-assignment-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ export type PanelAssignmentOutcome =
export type VariableAssignmentOutcome =
WorkspaceMutationOutcome<VariableAssignmentData | AssignmentDeclined>;

/** The narrow slice of the app an assignment needs. */
export interface LibraryAssignmentDeps {
/** The narrow slice of the app a panel assignment needs. */
export interface PanelAssignmentDeps {
/** The serialized, read-latest-at-dequeue write primitive every workspace
* producer commits through. */
mutateWorkspace: MutateWorkspace;
Expand All @@ -85,6 +85,10 @@ export interface LibraryAssignmentDeps {
/** The injected `crypto.randomUUID` seam — see the pure module's header for
* why assignment mints ids here instead of deriving them. */
genId(): string;
}

/** A variable assignment additionally needs live editor tabs for its dirty gate. */
export interface LibraryAssignmentDeps extends PanelAssignmentDeps {
/**
* The open tabs, read LIVE at commit time rather than captured at drop time.
* A function, not an array, precisely so the dirty check below re-reads them
Expand All @@ -103,7 +107,7 @@ const declined = (reason: LibraryAssignmentDecline): { candidate: null; data: As
* dequeue: a retry of the same drop must not silently become a second panel.
*/
export async function assignLibraryQueryToPanel(
deps: LibraryAssignmentDeps,
deps: PanelAssignmentDeps,
payload: LibraryQueryDragPayload,
dashboardId: string,
): Promise<PanelAssignmentOutcome> {
Expand Down
18 changes: 18 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -718,6 +718,17 @@ h1, h2, h3, h4, h5, h6 {
margin-top: 4px; padding: 8px 12px; border-top: 1px solid var(--border-faint);
font-size: var(--text-micro); color: var(--fg-faint); font-family: var(--mono);
}
/* Library row's two-stage Dashboard → panel chooser (#483). The confirmation
prose is a custom openMenu row; keep it in the same menu rhythm rather than
letting it fall back to unstyled user-agent text. */
.library-assign-menu { width: 288px; }
.library-assign-copy {
padding: 10px 12px; color: var(--fg-mute); font-size: var(--text-body);
line-height: var(--lh-body);
}
.library-assign-copy b { color: var(--fg); font-weight: var(--fw-semibold); }
.library-assign-add { color: var(--accent); font-weight: var(--fw-semibold); }
.library-assign-cancel { color: var(--fg-mute); }
/* Replace / New confirm dialog */
.fm-dialog-backdrop {
position: fixed; inset: 0; z-index: 130; background: var(--scrim);
Expand Down Expand Up @@ -944,6 +955,13 @@ h1, h2, h3, h4, h5, h6 {
color: var(--fg-faint); cursor: pointer; border-radius: var(--r-xs); flex-shrink: 0;
display: none; align-items: center; justify-content: center; padding: 0;
}
.saved-row .sv-assign {
display: inline-flex; opacity: 0; pointer-events: none;
}
.saved-row:hover .sv-assign,
.saved-row .sv-assign:focus-visible {
opacity: 1; pointer-events: auto;
}
.saved-row:hover .sv-act { display: inline-flex; }
.sv-act:hover { color: var(--fg); background: var(--bg-hover); }
.side-count { color: var(--fg-faint); font-weight: var(--fw-regular); }
Expand Down
16 changes: 14 additions & 2 deletions src/ui/dashboard-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,19 @@ function revealAssigned(app: DashboardTreeApp, dashboardId: string, rowKey: stri
if (list) focusRow(list, rowKey);
}

/**
* Reveal the Dashboard tree at one newly-created panel. Assignment callers use
* this shared settlement so drag/drop and the Library chooser both switch the
* upper sidebar role, expand the same ancestors, and arm the same keyboard row.
*/
export function revealAssignedPanel(
app: DashboardTreeApp, dashboardId: string, tileId: string,
): void {
app.state.upperRole.value = 'dashboards';
revealAssigned(app, dashboardId,
tileRowKey(app.currentWorkspace?.id ?? '', dashboardId, tileId), 'panels');
}

/**
* Run one accepted drop. The tree never writes a workspace document itself: it
* decodes, dispatches to the application command, and then reports.
Expand All @@ -461,8 +474,7 @@ async function dispatchDrop(
if (target.kind === 'panel') {
const outcome = await assignLibraryQueryToPanel(deps, payload, target.dashboardId);
if (outcome.ok && outcome.data && outcome.data.status === 'ok') {
revealAssigned(app, target.dashboardId,
tileRowKey(app.currentWorkspace?.id ?? '', target.dashboardId, outcome.data.tileId), 'panels');
revealAssignedPanel(app, target.dashboardId, outcome.data.tileId);
// Owner decision (2026-07-27), replacing #428's "do not automatically
// open": the point of dropping a query onto a Dashboard is to work on the
// panel you just made, so its OWNED COPY opens in the editor. The copy, not
Expand Down
19 changes: 17 additions & 2 deletions src/ui/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export function s(tag: string, props?: ElProps, ...children: unknown[]): SVGElem

/** A DOMRect-like anchor rectangle — only the edges `fixedAnchor` reads. */
export interface AnchorRect {
top?: number;
bottom: number;
left?: number;
right?: number;
Expand All @@ -89,9 +90,15 @@ export interface FixedAnchorOptions {
* under the trigger but its left inset is lowered so `left + panelW` never
* crosses `viewportW - min`. `panelW` alone (no `viewportW`) is ignored. */
panelW?: number;
/** Viewport/panel heights for bottom-edge placement. When both are present,
* the panel stays below if it fits, flips above the anchor when possible,
* and otherwise clamps inside the viewport gutter. */
viewportH?: number;
panelH?: number;
}

// Place a fixed-position popover anchored under a button. Returns
// Place a fixed-position popover anchored to a button: below by default, above
// when the supplied vertical geometry would cross the viewport bottom. Returns
// `{ top, left }`, or `{ top, right }` when `viewportW` is given WITHOUT
// `panelW` (right-align to the anchor's right edge). With BOTH `viewportW` and
// `panelW` it left-aligns but clamps the left inset so a `panelW`-wide panel
Expand All @@ -104,7 +111,15 @@ export function fixedAnchor(
): { top: number; left: number } | { top: number; right: number } {
const gap = opts.gap != null ? opts.gap : 6;
const min = opts.min != null ? opts.min : 8;
const top = rect.bottom + gap;
const naturalTop = rect.bottom + gap;
let top = naturalTop;
if (opts.viewportH != null && opts.panelH != null) {
const maxTop = Math.max(min, opts.viewportH - opts.panelH - min);
if (naturalTop > maxTop) {
const above = rect.top == null ? min : rect.top - gap - opts.panelH;
top = above >= min ? above : maxTop;
}
}
if (opts.viewportW != null && opts.panelW == null) {
return { top, right: Math.max(min, opts.viewportW - rect.right!) };
}
Expand Down
160 changes: 160 additions & 0 deletions src/ui/library-assign-menu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// Keyboard-accessible Library → Dashboard assignment (#483).
//
// This module owns only the chooser. The write semantics stay in
// application/library-assignment-service.ts, which is also what the Dashboard
// tree's drag/drop path calls. The first menu selects a Dashboard; openMenu
// closes it before its callback runs, so the callback can safely open the
// confirmation stage on the same trigger without stacking or racing menus.

import { assignLibraryQueryToPanel, libraryAssignmentMessage } from '../application/library-assignment-service.js';
import { h } from './dom.js';
import { Icon } from './icons.js';
import { openMenu, type MenuHandle, type MenuRow } from './menu.js';
import { flashToast } from './toast.js';
import type { LibraryQueryDragPayload } from '../core/library-drag.js';
import { shortIdFragments } from '../core/file-menu-model.js';
import { queryName } from '../core/saved-query.js';
import { UNTITLED_DASHBOARD } from '../application/dashboard-tree-model.js';
import { revealAssignedPanel } from './dashboard-tree.js';
import type { SavedQueryV2 } from '../generated/json-schema.types.js';
import type { App } from './app.types.js';

const keyboardOwnerChannel = (
app: Pick<App, 'acquireKeyboardOwner'>,
): ((owner: App['keyboardOwner']) => void) => {
let release: (() => void) | null = null;
return (owner) => {
release?.();
release = owner ? app.acquireKeyboardOwner(owner.kind) : null;
};
};

const dashboardCounts = (app: App): Map<string, number> => {
const counts = new Map<string, number>();
for (const dashboard of app.currentWorkspace?.dashboards ?? []) {
counts.set(dashboard.id, (counts.get(dashboard.id) ?? 0) + 1);
}
return counts;
};

async function assign(
app: App, payload: LibraryQueryDragPayload, dashboardId: string,
): Promise<void> {
const surfaceGeneration = app.captureSurfaceGeneration();
let currentAtNotification: boolean | null = null;
const outcome = await assignLibraryQueryToPanel({
mutateWorkspace: app.mutateWorkspace,
// Resolve the live renderer hook after the durable write. A surface
// transition replaces this property while persistence is in flight. Record
// staleness BEFORE the hook rebuilds a live Dashboard (which can itself
// advance the renderer generation).
onWorkspaceExternallyChanged: (info) => {
currentAtNotification = app.isSurfaceGenerationCurrent(surfaceGeneration);
app.onWorkspaceExternallyChanged(info);
},
genId: app.genId,
}, payload, dashboardId);
// The durable write and live-renderer notification stand, but a slow command
// must not navigate or toast over a surface the user chose afterwards.
if (!(currentAtNotification ?? app.isSurfaceGenerationCurrent(surfaceGeneration))) return;
if (outcome.ok && outcome.data?.status === 'ok') {
// Match the drag path's post-command behavior: reveal/select the new Panel
// in the Dashboard tree, then work on its independent owned copy — never
// the Library source.
revealAssignedPanel(app, dashboardId, outcome.data.tileId);
app.openSavedQuery(outcome.data.queryId);
}
const message = libraryAssignmentMessage(outcome);
if (message !== null) flashToast(message, { document: app.document });
}

function openConfirmation(
app: App, query: SavedQueryV2, dashboardId: string, dashboardTitle: string,
trigger: HTMLElement, payload: LibraryQueryDragPayload,
): MenuHandle {
const name = queryName(query);
return openMenu({
document: app.document,
trigger,
menuClass: 'library-assign-menu',
ariaLabel: `Confirm adding ${name} to ${dashboardTitle}`,
onKeyboardOwnerChange: keyboardOwnerChannel(app),
rows: [
{
kind: 'custom',
node: h('div', { class: 'library-assign-copy' },
'Add “', h('b', null, name), '” to “', h('b', null, dashboardTitle),
'” as a new panel. The panel gets an independent copy you can edit.'),
},
{
kind: 'item', label: 'Add', icon: Icon.plus(),
extraClass: 'library-assign-add',
onClick: () => { void assign(app, payload, dashboardId); },
},
{
kind: 'item', label: 'Cancel', extraClass: 'library-assign-cancel',
autofocus: true, onClick: () => trigger.focus(),
},
],
});
}

/** Open the Dashboard chooser for one rendered Library query row. */
export function openLibraryAssignMenu(
app: App, query: SavedQueryV2, trigger: HTMLElement,
): MenuHandle {
const workspace = app.currentWorkspace;
const name = queryName(query);
const payload: LibraryQueryDragPayload = {
kind: 'library-query',
workspaceId: workspace?.id ?? '',
queryId: query.id,
};
const counts = dashboardCounts(app);
const dashboards = workspace?.dashboards ?? [];
const fragments = shortIdFragments(dashboards.map((dashboard) => dashboard.id));
const occurrences = new Map<string, number>();
const rows: MenuRow[] = [
{ kind: 'section', label: 'Add query to dashboard' },
...dashboards.map((dashboard, index): MenuRow => {
const title = dashboard.title.trim() || UNTITLED_DASHBOARD;
const count = counts.get(dashboard.id) ?? 0;
const ambiguous = count > 1;
const occurrence = (occurrences.get(dashboard.id) ?? 0) + 1;
occurrences.set(dashboard.id, occurrence);
const tileCount = dashboard.tiles.length;
const meta = `${tileCount} ${tileCount === 1 ? 'tile' : 'tiles'} · ${fragments[index]}`
+ (ambiguous ? ` · duplicate ${occurrence}/${count}` : '');
return {
kind: 'item',
label: title,
meta,
trailing: Icon.chev(),
disabled: ambiguous,
reason: ambiguous ? 'Two dashboards share this id' : null,
onClick: () => openConfirmation(
app, query, dashboard.id, title, trigger, payload,
),
};
}),
];
if (dashboards.length === 0) {
rows.push({
kind: 'custom',
node: h('div', { class: 'library-assign-copy' },
'Create or open a dashboard before adding this query as a panel.'),
});
rows.push({
kind: 'item', label: 'Cancel', extraClass: 'library-assign-cancel',
autofocus: true, onClick: () => trigger.focus(),
});
}
return openMenu({
document: app.document,
trigger,
menuClass: 'library-assign-menu',
ariaLabel: `Choose a dashboard for ${name}`,
onKeyboardOwnerChange: keyboardOwnerChannel(app),
rows,
});
}
21 changes: 18 additions & 3 deletions src/ui/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ export interface MenuOptions {
/** Extra class(es) appended to the mounted `.file-menu` element (e.g.
* `dash-style-menu` for the Dashboard style picker's width override). */
menuClass?: string;
/** Accessible name for the popup's `role="menu"`. Callers with a visible
* heading should still supply the interaction's concise purpose here. */
ariaLabel?: string;
/** Called once the menu is fully torn down, however it closed (Escape,
* overlay click, an item's own click, or an explicit `handle.close()`) —
* lets the caller clear its own open/closed bookkeeping. */
Expand Down Expand Up @@ -157,7 +160,11 @@ export function openMenu(opts: MenuOptions): MenuHandle {
return btn;
};

const menu = h('div', { class: menuClass ? `file-menu ${menuClass}` : 'file-menu', role: 'menu' },
const menu = h('div', {
class: menuClass ? `file-menu ${menuClass}` : 'file-menu',
role: 'menu',
...(opts.ariaLabel ? { 'aria-label': opts.ariaLabel } : {}),
},
...rows.map(buildRow));
const overlay = h('div', { class: 'fm-overlay', onclick: () => close() });

Expand Down Expand Up @@ -193,12 +200,20 @@ export function openMenu(opts: MenuOptions): MenuHandle {
trigger.setAttribute('aria-expanded', 'true');
doc.body.appendChild(overlay);
doc.body.appendChild(menu);
const viewportH = doc.defaultView?.innerHeight;
menu.style.position = 'fixed';
if (viewportH != null) {
// A menu taller than the viewport remains keyboard/pointer reachable rather
// than overflowing both above and below after placement is clamped.
menu.style.maxHeight = Math.max(0, viewportH - 16) + 'px';
menu.style.overflowY = 'auto';
}
const r = trigger.getBoundingClientRect();
const panelH = menu.getBoundingClientRect().height;
// fixedAnchor's return type is a `{top,left}` / `{top,right}` union (the
// right-align branch only fires when a `viewportW` option is passed) — this
// call site never passes one, so it's always the `{top,left}` shape.
const a = fixedAnchor(r) as { top: number; left: number };
menu.style.position = 'fixed';
const a = fixedAnchor(r, { viewportH, panelH }) as { top: number; left: number };
menu.style.top = a.top + 'px';
menu.style.left = a.left + 'px';
doc.addEventListener('keydown', onKey, true);
Expand Down
8 changes: 8 additions & 0 deletions src/ui/saved-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { isAutoRunnable } from '../core/sql-split.js';
import { isQuerylessPanel } from '../core/panel-cfg.js';
import { queryDescription, queryFavorite, queryName, queryPanel, queryView } from '../core/saved-query.js';
import { libraryQueries } from '../dashboard/model/query-ownership.js';
import { openLibraryAssignMenu } from './library-assign-menu.js';
import type { App } from './app.types.js';
import type { SavedQueryV2 } from '../generated/json-schema.types.js';

Expand Down Expand Up @@ -261,6 +262,13 @@ function renderSaved(app: App, list: HTMLElement): void {
h('div', { class: 'top' },
star,
h('span', { class: 'name' }, name),
h('button', {
class: 'sv-act sv-assign', title: 'Add to dashboard…', 'aria-label': 'Add to dashboard…',
onclick: (e: Event) => {
e.stopPropagation();
openLibraryAssignMenu(app, q, e.currentTarget as HTMLElement);
},
}, Icon.plus()),
h('button', {
class: 'sv-act', title: 'Edit name & description',
onclick: (e: Event) => {
Expand Down
Loading