Dashboard palette: switch dashboard, publish, settings; ⌘S publishes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 22:05:39 +02:00
co-authored by Claude Opus 5.5
parent 72b4cf3556
commit 0ef7d027b8
3 changed files with 156 additions and 85 deletions
@@ -39,6 +39,7 @@ import { appPath } from "@/lib/portal"
import { useShortcuts } from "@/lib/shortcuts"
import { cn } from "@/lib/utils"
import { handleError } from "@/utils"
import { DashboardPalette } from "./DashboardPalette"
import {
areaOf,
CanvasSurface,
@@ -66,7 +67,6 @@ import { PaletteProvider } from "./settings"
import { useUi } from "./ui"
import { showTitle } from "./ui/core/config"
import { LookProvider, useCanvasRoot } from "./ui/core/look"
import { WidgetPalette } from "./WidgetPalette"
import {
WIDGET_LABELS,
WIDGET_SIZES,
@@ -335,11 +335,25 @@ export function DashboardEditor({
setSelected(id)
}
// ⌘P, as on the flow canvas. Reachable while typing, so a settings field
// having focus does not hide it.
const openSettings = () => {
setSelected(null)
setSettingsOpen(true)
}
// As on the flow canvas, and reachable while typing for the same reasons.
// Outside edit mode both keys stay the browser's.
useShortcuts(
{ "mod+p": () => (edit ? setPaletteOpen((open) => !open) : false) },
["mod+p"],
{
"mod+p": () => (edit ? setPaletteOpen((open) => !open) : false),
// An edit still waiting for its autosave counts: publishing flushes it.
"mod+s": () => {
if (!edit) return false
if (!publish.isPending && (hasDraft || timer.current)) {
void publishDashboard()
}
},
},
["mod+p", "mod+s"],
)
const patch = (id: string, changes: Partial<WidgetDef>) =>
@@ -639,14 +653,9 @@ export function DashboardEditor({
"text-muted-foreground",
settingsOpen && "text-primary",
)}
onClick={() => {
if (settingsOpen) {
setSettingsOpen(false)
return
}
setSelected(null)
setSettingsOpen(true)
}}
onClick={() =>
settingsOpen ? setSettingsOpen(false) : openSettings()
}
aria-pressed={settingsOpen}
aria-label="Dashboard settings"
data-testid="edit-dashboard"
@@ -718,10 +727,12 @@ export function DashboardEditor({
{edit ? (
<>
<WidgetPalette
<DashboardPalette
open={paletteOpen}
onOpenChange={setPaletteOpen}
onAdd={addWidget}
onSettings={openSettings}
onPublish={hasDraft ? () => void publishDashboard() : undefined}
/>
<WidgetPanel
@@ -0,0 +1,131 @@
import { useQuery } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router"
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import { dashboardsQueryOptions } from "./queries"
import {
INPUT_WIDGETS,
WIDGET_DESCRIPTIONS,
WIDGET_LABELS,
type WidgetKind,
} from "./widgets"
const KINDS = Object.keys(WIDGET_LABELS) as WidgetKind[]
const GROUPS = [
["Show a value", KINDS.filter((kind) => !INPUT_WIDGETS.has(kind))],
["Control something", KINDS.filter((kind) => INPUT_WIDGETS.has(kind))],
] as const
/**
* ⌘P on a dashboard being edited: add a widget, jump to another dashboard, or
* publish — the flow canvas's palette, for this editor.
*/
export function DashboardPalette({
open,
onOpenChange,
onAdd,
onSettings,
onPublish,
}: {
open: boolean
onOpenChange: (open: boolean) => void
onAdd: (kind: WidgetKind) => void
onSettings: () => void
/** Absent while there is nothing to publish. */
onPublish?: () => void
}) {
const navigate = useNavigate()
const { data } = useQuery({ ...dashboardsQueryOptions(), enabled: open })
// Unmounted rather than faded, for the reason the flow palette gives.
if (!open) return null
const dashboards = data?.data ?? []
const close = (action: () => void) => {
onOpenChange(false)
action()
}
return (
<CommandDialog
open={open}
onOpenChange={onOpenChange}
title="Commands"
description="Add a widget, switch dashboard, or publish"
>
<CommandInput placeholder="Add a widget, switch dashboard, publish…" />
<CommandList>
<CommandEmpty>Nothing matches that.</CommandEmpty>
{GROUPS.map(([heading, kinds]) => (
<CommandGroup key={heading} heading={heading}>
{kinds.map((kind) => (
<CommandItem
key={kind}
value={`${WIDGET_LABELS[kind]} ${WIDGET_DESCRIPTIONS[kind]}`}
data-testid={`add-widget-${kind}`}
onSelect={() => close(() => onAdd(kind))}
>
<span className="flex flex-col">
<span>{WIDGET_LABELS[kind]}</span>
<span className="text-xs text-muted-foreground">
{WIDGET_DESCRIPTIONS[kind]}
</span>
</span>
</CommandItem>
))}
</CommandGroup>
))}
{dashboards.length > 0 ? (
<CommandGroup heading="Dashboards">
{dashboards.map((dashboard) => (
<CommandItem
key={dashboard.name}
value={`dashboard ${dashboard.name} ${dashboard.title}`}
onSelect={() =>
close(() =>
// Editing a neighbour is the same job, as on the rail.
navigate({
to: "/dashboards/$name",
params: { name: dashboard.name },
search: { edit: true },
}),
)
}
>
{dashboard.title || dashboard.name}
</CommandItem>
))}
</CommandGroup>
) : null}
<CommandGroup heading="Actions">
{onPublish ? (
<CommandItem
value="publish dashboard"
onSelect={() => close(onPublish)}
>
Publish this dashboard
</CommandItem>
) : null}
<CommandItem
value="dashboard settings"
onSelect={() => close(onSettings)}
>
Dashboard settings
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
)
}
@@ -1,71 +0,0 @@
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
INPUT_WIDGETS,
WIDGET_DESCRIPTIONS,
WIDGET_LABELS,
type WidgetKind,
} from "./widgets"
const KINDS = Object.keys(WIDGET_LABELS) as WidgetKind[]
const GROUPS = [
["Show a value", KINDS.filter((kind) => !INPUT_WIDGETS.has(kind))],
["Control something", KINDS.filter((kind) => INPUT_WIDGETS.has(kind))],
] as const
/** ⌘P on a dashboard being edited: the flow canvas's add-a-node palette, for widgets. */
export function WidgetPalette({
open,
onOpenChange,
onAdd,
}: {
open: boolean
onOpenChange: (open: boolean) => void
onAdd: (kind: WidgetKind) => void
}) {
// Unmounted rather than faded, for the reason the flow palette gives.
if (!open) return null
return (
<CommandDialog
open={open}
onOpenChange={onOpenChange}
title="Add a widget"
description="Search the widget types"
>
<CommandInput placeholder="Add a widget…" />
<CommandList>
<CommandEmpty>Nothing matches that.</CommandEmpty>
{GROUPS.map(([heading, kinds]) => (
<CommandGroup key={heading} heading={heading}>
{kinds.map((kind) => (
<CommandItem
key={kind}
value={`${WIDGET_LABELS[kind]} ${WIDGET_DESCRIPTIONS[kind]}`}
data-testid={`add-widget-${kind}`}
onSelect={() => {
onOpenChange(false)
onAdd(kind)
}}
>
<span className="flex flex-col">
<span>{WIDGET_LABELS[kind]}</span>
<span className="text-xs text-muted-foreground">
{WIDGET_DESCRIPTIONS[kind]}
</span>
</span>
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</CommandDialog>
)
}