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

### Added
- **A Dashboard row can create a blank Panel directly from the Dashboards
tree** (#515). Its new plus action opens the shared name/description dialog,
then atomically commits one empty panel-role query and one canonically laid
out tile—without selecting or copying a Library query. The action is
keyboard-accessible, unavailable for ambiguous or 100-panel Dashboards, and
a successful Add reveals the new Panel and opens its clean linked tab with
focus in the SQL editor.
- **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
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,14 @@ preference — it never adds or removes a Dashboard panel. Saving or editing a q
form with both a name and a description field; the description shows under the
row and is included in Markdown/SQL exports.

Each unambiguous Dashboard row also has a direct **+ Add panel** action. It asks
for a required Panel name and optional description, atomically creates an empty
Dashboard-owned query plus its tile, and opens that clean linked query in the
SQL editor. This is intentionally separate from **Add to dashboard…** on a
Library row: creating a blank Panel never selects, copies, or modifies a Library
query. A Dashboard at the 100-panel schema limit exposes the action as
unavailable instead of opening a dialog that cannot commit.

The queries plus the workspace's Dashboard collection form a workspace
(`StoredWorkspaceV3`; records written before that contract are read and
migrated automatically). A browser profile can keep multiple workspaces in
Expand Down
38 changes: 38 additions & 0 deletions docs/ADR-0003-dashboard-viewing.md
Original file line number Diff line number Diff line change
Expand Up @@ -774,6 +774,44 @@ 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 (#515, 2026-07-28): blank Panel creation is one owned aggregate mutation

A Dashboard row now places **Add panel** immediately before its edit and delete
actions. This is not Library assignment: the two-field dialog supplies the new
query's name and optional description, and the command constructs a blank
`spec.dashboard.role: "panel"` query directly. No Library source is resolved,
copied, favorited, or modified.

The pure candidate builder lives in `dashboard/application`, while its
read-latest-at-dequeue wrapper and injected id generation live in
`application`. Query and tile ids are minted once per Add attempt. Inside the
queued transform the command strictly re-resolves the Dashboard, re-checks the
100-tile limit, rejects workspace-wide query-id and Dashboard-local tile-id
collisions, appends the query, and delegates tile creation to
`add-query-instance`. That canonical path remains the sole owner of initial
placement, layout normalization, and grid-fallback regeneration. The target
Dashboard revision advances once; every unrelated query and Dashboard is
retained unchanged. Aggregate validation and persistence see one candidate, so
an orphan query or dangling tile is never published on failure.

Success settlement is deliberately a post-dialog-close callback. The dialog
first tears down and performs its ordinary return-focus step; only then does
the tree reveal the new Panel and call the existing saved-query open path.
The creation service does not invoke the active Dashboard's workspace-refresh
hook before returning: `mutateWorkspace` has already projected the committed
aggregate, while an eager Dashboard rerender would force-close the dialog and
discard that post-close navigation.
That path switches to the Query surface, seeds the linked tab's committed-token
baseline, and focuses the SQL editor, so dialog teardown cannot steal focus back
to the hidden plus trigger. Cancel, Escape, backdrop close, stale targets,
collisions, validation errors, and persistence failures perform no navigation;
failed writes keep the entered values and diagnostic in the open dialog.
An unexpected rejected write is converted to the same recoverable dialog state:
the fields remain, a generic diagnostic is announced, and every dismissal path
is re-enabled. If persistence succeeds only after the route has moved on,
settlement instead closes silently without claiming failure or running the
old route's reveal/open/focus callback.

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

The #457 addendum above ("Losing Test is accepted...") deferred re-hosting the
Expand Down
15 changes: 13 additions & 2 deletions src/application/dashboard-tree-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import type { DashboardTreeGroup, DashboardTreeUiState } from '../core/dashboard
import { encodeKeyPart, groupStateKey } from '../core/dashboard-tree-ui-state.js';
import { inferDashboardVariables } from '../core/dashboard-variables.js';
import { buildQueryOwnershipIndex } from '../dashboard/model/query-ownership.js';
import { PORTABLE_LIMITS } from '../dashboard/model/portable-limits.js';
import { queryDashboardRole } from '../dashboard/model/workspace-semantics.js';
import type { DashboardVariable } from '../core/dashboard-variables.js';
import type { LibraryDropTarget } from '../core/library-drag.js';
Expand Down Expand Up @@ -139,6 +140,7 @@ export type DashboardTreeCommand =
* answered exactly once, here.
*/
export type DashboardTreeActionKind =
| 'add-panel'
| 'edit-dashboard'
| 'delete-dashboard'
| 'edit-panel'
Expand Down Expand Up @@ -440,7 +442,9 @@ const WRONG_ROLE_REASON =
* states, applied to the Dashboard identity delete already refuses on
* (`dashboard-duplicate` in `removeDashboardDocument`/`removeDashboardPanel`). */
const AMBIGUOUS_DASHBOARD_REASON =
'Two dashboards in this workspace share this id, so it cannot be edited or removed here.';
'Two dashboards in this workspace share this id, so neither can be changed here.';
const DASHBOARD_TILE_LIMIT_REASON =
'This dashboard already has the maximum of 100 panels, so another panel cannot be added.';
/** Two tiles in the SAME Dashboard carry this id — even when they reference
* different queries. Each would otherwise look, independently, like that
* query's sole owner (`ownersOfQuery` cannot tell them apart), so this is
Expand Down Expand Up @@ -653,7 +657,7 @@ export function deriveDashboardTree(
invalid: null,
severity: null,
diagnostic: null,
// #494: the Dashboard row's own two direct controls. Its `⋯` menu is
// #494/#515: the Dashboard row's own three direct controls. Its `⋯` menu is
// gone — *Open in Edit* was its last item, and a menu button that opens
// a one-item menu beside two real controls is chrome, not vocabulary.
// Shift-click / Shift+Enter remain the Edit gesture (`shift` below).
Expand All @@ -664,12 +668,19 @@ export function deriveDashboardTree(
// commit would only refuse is the exact bug this closes.
actions: dashboardIdDuplicated
? [
unavailableAction('add-panel', 'Add panel to ' + (title || UNTITLED_DASHBOARD),
AMBIGUOUS_DASHBOARD_REASON),
unavailableAction('edit-dashboard', 'Edit dashboard ' + (title || UNTITLED_DASHBOARD),
AMBIGUOUS_DASHBOARD_REASON),
unavailableAction('delete-dashboard', 'Delete dashboard ' + (title || UNTITLED_DASHBOARD),
AMBIGUOUS_DASHBOARD_REASON),
]
: [
...(tiles.length >= PORTABLE_LIMITS.maxTilesPerDashboard
? [unavailableAction('add-panel', 'Add panel to ' + (title || UNTITLED_DASHBOARD),
DASHBOARD_TILE_LIMIT_REASON)]
: [action('add-panel', 'Add panel to ' + (title || UNTITLED_DASHBOARD),
'Add panel', { kind: 'dashboard', dashboardId: dashboard.id })]),
action('edit-dashboard', 'Edit dashboard ' + (title || UNTITLED_DASHBOARD),
'Edit dashboard title & description', { kind: 'dashboard', dashboardId: dashboard.id }),
action('delete-dashboard', 'Delete dashboard ' + (title || UNTITLED_DASHBOARD),
Expand Down
65 changes: 65 additions & 0 deletions src/application/panel-creation-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// The async application command around the pure blank-Panel candidate (#515).
// IDs are minted exactly once per Add attempt, then the target and all limits
// are re-resolved inside `mutateWorkspace` against committed dequeue-time truth.

import { createPanelCandidate } from '../dashboard/application/panel-creation.js';
import type { PanelCreationAbort } from '../dashboard/application/panel-creation.js';
import type { MutateWorkspace, WorkspaceMutationOutcome } from '../state.js';

export type PanelCreationData = { status: 'ok'; queryId: string; tileId: string };
export type PanelCreationDeclined = { status: 'declined'; reason: PanelCreationAbort };
export type PanelCreationOutcome =
WorkspaceMutationOutcome<PanelCreationData | PanelCreationDeclined>;

export interface PanelCreationDeps {
mutateWorkspace: MutateWorkspace;
genId(): string;
}

const declined = (
reason: PanelCreationAbort,
): { candidate: null; data: PanelCreationDeclined } => ({
candidate: null,
data: { status: 'declined', reason },
});

export async function createDashboardPanel(
deps: PanelCreationDeps,
dashboardId: string,
name: string,
description: string,
): Promise<PanelCreationOutcome> {
const queryId = deps.genId();
const tileId = deps.genId();
const outcome = await deps.mutateWorkspace<PanelCreationData | PanelCreationDeclined>((latest) => {
if (latest === null) return declined('dashboard-missing');
const result = createPanelCandidate({
latest, dashboardId, queryId, tileId, name, description,
});
if (!result.ok) return declined(result.reason);
return { candidate: result.workspace, data: { status: 'ok', ...result.data } };
});
return outcome;
}

const DECLINE_MESSAGES: Record<PanelCreationAbort, string> = {
'dashboard-missing': 'That dashboard is no longer part of this workspace.',
'dashboard-ambiguous': 'That dashboard is ambiguous and cannot be changed.',
'tile-limit': 'That dashboard already has the maximum of 100 panels.',
'id-collision': 'Could not create this panel because an id already exists. Please try again.',
'blank-name': 'Enter a panel name.',
};

/**
* A dialog diagnostic, `null` for a route-current committed creation, or
* `undefined` when the route became stale and the dialog should dismiss
* without claiming failure or running route-local success settlement.
*/
export function panelCreationMessage(outcome: PanelCreationOutcome): string | null | undefined {
if (outcome.ok) return null;
if (!outcome.aborted) {
return outcome.diagnostics[0]?.message || 'Could not save this panel.';
}
const data = outcome.data;
return data?.status === 'declined' ? DECLINE_MESSAGES[data.reason] : undefined;
}
90 changes: 90 additions & 0 deletions src/dashboard/application/panel-creation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Creating one blank Dashboard-owned Panel (#515). Pure — no DOM,
// persistence, or id generation.
//
// The query and tile are one aggregate candidate: callers either validate and
// commit both or observe neither. Placement deliberately goes through the
// canonical `add-query-instance` command so this path inherits the same
// size-hint defaults, layout normalization, and grid-fallback regeneration as
// every other panel add.

import { SPEC_VERSION } from '../../core/saved-query.js';
import { PORTABLE_LIMITS } from '../model/portable-limits.js';
import { findDashboardStrict, replaceDashboard } from '../../workspace/workspace-dashboards.js';
import { applyCommand } from './dashboard-commands.js';
import type { ApplyCommandResult } from './dashboard-commands.js';
import { createQueryResolver } from './dashboard-query-resolver.js';
import { resolveLayoutPluginSync } from '../layouts/layout-registry.js';
import type { SavedQueryV2, StoredWorkspaceV5 } from '../../generated/json-schema.types.js';

export type PanelCreationAbort =
| 'dashboard-missing'
| 'dashboard-ambiguous'
| 'tile-limit'
| 'id-collision'
| 'blank-name';

export type PanelCreationResult =
| {
ok: true;
workspace: StoredWorkspaceV5;
data: { queryId: string; tileId: string };
}
| { ok: false; reason: PanelCreationAbort };

export interface CreatePanelCandidateInput {
latest: StoredWorkspaceV5;
dashboardId: string;
queryId: string;
tileId: string;
name: string;
description: string;
}

const fail = (reason: PanelCreationAbort): PanelCreationResult => ({ ok: false, reason });

/**
* Build the complete candidate for one new empty panel.
*
* Existing query and Dashboard objects are retained byte-for-byte except for
* the one target Dashboard, whose revision advances exactly once.
*/
export function createPanelCandidate(input: CreatePanelCandidateInput): PanelCreationResult {
const { latest, dashboardId, queryId, tileId } = input;
const lookup = findDashboardStrict(latest, dashboardId);
if (lookup.status !== 'ok') {
return fail(lookup.status === 'missing' ? 'dashboard-missing' : 'dashboard-ambiguous');
}
const base = lookup.dashboard;
if (base.tiles.length >= PORTABLE_LIMITS.maxTilesPerDashboard) return fail('tile-limit');
if (latest.queries.some((query) => query.id === queryId)
|| base.tiles.some((tile) => tile.id === tileId)) {
return fail('id-collision');
}

const name = input.name.trim();
if (name === '') return fail('blank-name');
const description = input.description.trim();
const query: SavedQueryV2 = {
id: queryId,
sql: '',
specVersion: SPEC_VERSION,
spec: {
name,
...(description === '' ? {} : { description }),
dashboard: { role: 'panel' },
},
};
const queries = [...latest.queries, query];
const applied = applyCommand(base, { type: 'add-query-instance', queryId }, {
resolver: createQueryResolver(queries),
genTileId: () => tileId,
plugin: resolveLayoutPluginSync(base.layout),
}) as Extract<ApplyCommandResult, { ok: true }>;
const normalized = resolveLayoutPluginSync(applied.dashboard.layout).normalize(applied.dashboard);
const workspace = replaceDashboard({ ...latest, queries }, dashboardId, {
...normalized,
revision: base.revision + 1,
}) as StoredWorkspaceV5;

return { ok: true, workspace, data: { queryId, tileId } };
}
50 changes: 48 additions & 2 deletions src/ui/dashboard-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ import { commitPanelQueryMetadata } from '../application/dashboard-panel-metadat
import type {
PanelMetadataDeps, PanelMetadataOutcome,
} from '../application/dashboard-panel-metadata.js';
import {
createDashboardPanel, panelCreationMessage,
} from '../application/panel-creation-service.js';
import type { PanelCreationData } from '../application/panel-creation-service.js';
import { closeOpenDialogShell, openMetadataDialog } from './dialog-shell.js';
import {
assignLibraryQuerySqlToVariable, assignLibraryQueryToPanel, libraryAssignmentMessage,
Expand Down Expand Up @@ -841,7 +845,8 @@ function buildActionButton(
// to look and announce like a delete (#494 — a row's vocabulary must not
// change with availability), and its `confirm` is null precisely because it
// will never be asked.
const destructive = act.kind !== 'edit-dashboard' && act.kind !== 'edit-panel';
const destructive = act.kind !== 'add-panel'
&& act.kind !== 'edit-dashboard' && act.kind !== 'edit-panel';
const trigger: HTMLButtonElement = h('button', {
class: 'dash-tree-act'
+ (destructive ? ' dash-tree-act-danger' : '')
Expand All @@ -866,7 +871,9 @@ function buildActionButton(
app._dashTreeArbiter?.cancelFor(row.key);
runAction(app, doc, row, act, trigger);
},
}, act.kind === 'edit-dashboard' || act.kind === 'edit-panel' ? Icon.pencil() : Icon.trash());
}, act.kind === 'add-panel'
? Icon.plus()
: (act.kind === 'edit-dashboard' || act.kind === 'edit-panel' ? Icon.pencil() : Icon.trash()));
return trigger;
}

Expand Down Expand Up @@ -904,6 +911,7 @@ const returnFocusAfterDialog = (
* question above it already named the resource, and "OK" next to a sentence
* full of names is where a destructive click gets made by accident. */
const CONFIRM_LABELS: Record<DashboardTreeActionKind, string> = {
'add-panel': '',
'edit-dashboard': '',
'edit-panel': '',
'delete-dashboard': 'Delete dashboard',
Expand All @@ -927,6 +935,10 @@ function runAction(
): void {
const target = act.target;
if (target === null) return;
if (act.kind === 'add-panel') {
openPanelCreationDialog(app, doc, trigger, row);
return;
}
if (act.kind === 'edit-dashboard') { openDashboardMetadataDialog(app, doc, trigger, row); return; }
if (act.kind === 'edit-panel') {
openPanelMetadataDialog(app, doc, trigger, row, target as PanelActionTarget);
Expand All @@ -940,6 +952,40 @@ function runAction(
* each use: the model pairs kind and target by construction. */
type PanelActionTarget = Extract<DashboardTreeActionTarget, { kind: 'panel' }>;

/** Create a blank owned query + tile, then reveal and open it only after the
* dialog has closed so its return-focus step cannot steal focus from SQL. */
function openPanelCreationDialog(
app: DashboardTreeApp, doc: Document, trigger: HTMLButtonElement, row: DashboardTreeRow,
): void {
closeOpenDialogShell();
trigger.setAttribute('aria-expanded', 'true');
let created: PanelCreationData | null = null;
openMetadataDialog({ document: doc, acquireKeyboardOwner: app.acquireKeyboardOwner }, {
title: 'Add panel',
nameLabel: 'Panel name',
descriptionLabel: 'Panel description',
name: '',
description: '',
confirmLabel: 'Add',
idPrefix: 'panel-create',
lockCloseWhileConfirming: true,
returnFocusTo: returnFocusAfterDialog(app, trigger, row.key),
onClose: () => trigger.setAttribute('aria-expanded', 'false'),
onSuccessAfterClose: () => {
revealAssignedPanel(app, row.dashboardId, created!.tileId);
app.openSavedQuery(created!.queryId);
},
onConfirm: async ({ name, description }) => {
const outcome = await createDashboardPanel({
mutateWorkspace: app.mutateWorkspace,
genId: app.genId,
}, row.dashboardId, name, description);
if (outcome.ok) created = outcome.data as PanelCreationData;
return panelCreationMessage(outcome);
},
});
}

/** Ask before destroying something, anchored on the control that asked. */
function confirmDestructive(
doc: Document, trigger: HTMLButtonElement, act: DashboardTreeAction, go: () => void,
Expand Down
Loading