Rework the dashboards onto the flow canvas
One shell for both editors. Flows and dashboards each get a searchable
overview under the padded shell, their editors move to the full-bleed
canvas, and the floating chrome is shared: a title bar that only says
what you are looking at, and a bottom dock carrying everything else —
the flow bar's status, settings and Publish moved down there, the
add-flow button moved to the overview.
Dashboards gain the rest of M4's visualization work:
- widgets are picked by clicking them, with the header as the drag
handle so a slider still slides and a switch still flips while
editing; settings moved into the flows' SidePanel
- react-grid-layout for drag and edge-resize, so the stored x/y finally
mean something; a dashboard nobody arranged is shelf-packed once
- a per-dashboard grid size, so a panel can be matched to its screen
- the chart widget, drawn with uPlot: several messages on one axis, fed
from the stored history plus the live socket tail, coloured from the
new --chart-1..5 ramp
- /view/{name}: the URL a wall panel is pointed at — no sidebar, no
footer, no editing, and no editor code, since routes are split
- a widget wired to a payload type it cannot carry, or wired to nothing
at all, carries the same red dot a failing node does; the picker
records the type it bound and WidgetDef refuses a mismatch on save
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A
This commit is contained in:
@@ -1,45 +1,78 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { ChevronLeft, ChevronRight, Plus, X } from "lucide-react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
import type { ApiError, DashboardDef_Output, WidgetDef } from "@/client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
Check,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Plus,
|
||||
Settings2,
|
||||
} from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { GridLayout, type Layout, useContainerWidth } from "react-grid-layout"
|
||||
import "react-grid-layout/css/styles.css"
|
||||
|
||||
import {
|
||||
type ApiError,
|
||||
type DashboardDef_Output,
|
||||
DashboardsService,
|
||||
type Placement,
|
||||
type WidgetDef,
|
||||
} from "@/client"
|
||||
import { CanvasTitle } from "@/components/Flow/CanvasTitle"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { slideUp, transitions } from "@/lib/motion"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { handleError } from "@/utils"
|
||||
import {
|
||||
columnsOf,
|
||||
type Dashboard,
|
||||
DashboardView,
|
||||
GRID_GAP,
|
||||
isPlaced,
|
||||
pagesOf,
|
||||
SectionGrid,
|
||||
placement,
|
||||
ROW_HEIGHT,
|
||||
sectionsOf,
|
||||
widgetStyle,
|
||||
widgetsOf,
|
||||
} from "./DashboardView"
|
||||
import { messageCatalogQueryOptions, useSaveDashboard } from "./queries"
|
||||
import { DashboardPanel, WidgetPanel } from "./panels"
|
||||
import { dashboardKeys, useSaveDashboard } from "./queries"
|
||||
import {
|
||||
INPUT_WIDGETS,
|
||||
WIDGET_LABELS,
|
||||
WIDGET_SIZES,
|
||||
WidgetBody,
|
||||
WidgetFrame,
|
||||
type WidgetKind,
|
||||
widgetIssue,
|
||||
} from "./widgets"
|
||||
|
||||
// Charts are stored and validated, but not drawn yet, so they are not offered.
|
||||
const KINDS = (Object.keys(WIDGET_LABELS) as WidgetKind[]).filter(
|
||||
(kind) => kind !== "chart",
|
||||
)
|
||||
const KINDS = Object.keys(WIDGET_LABELS) as WidgetKind[]
|
||||
|
||||
/** How long to sit on edits before saving, so typing is not a save per key. */
|
||||
const AUTOSAVE_MS = 800
|
||||
|
||||
/**
|
||||
* Controls a widget owns. A press on one of these is the widget's own — a
|
||||
* slider still slides in edit mode — so it never counts as picking the widget.
|
||||
*/
|
||||
const INTERACTIVE =
|
||||
"button, a, input, select, textarea, [role='switch'], [role='combobox'], [role='slider'], .react-resizable-handle"
|
||||
|
||||
function nextId(dashboard: DashboardDef_Output, type: string): string {
|
||||
const taken = new Set(
|
||||
pagesOf(dashboard).flatMap((page) =>
|
||||
@@ -53,15 +86,82 @@ function nextId(dashboard: DashboardDef_Output, type: string): string {
|
||||
return candidate
|
||||
}
|
||||
|
||||
/**
|
||||
* Positions for a dashboard nobody has arranged yet.
|
||||
*
|
||||
* Widths were the only thing the old editor set, so every widget sits at 0,0.
|
||||
* Shelf-packing them into the grid is what the browser was doing implicitly;
|
||||
* writing it down is what lets them be dragged from there.
|
||||
*/
|
||||
function packed(widgets: WidgetDef[], columns: number): Layout {
|
||||
let x = 0
|
||||
let y = 0
|
||||
let shelf = 0
|
||||
return widgets.map((widget) => {
|
||||
const { w = 3, h = 2 } = placement(widget)
|
||||
const width = Math.min(columns, Math.max(1, w))
|
||||
if (x + width > columns) {
|
||||
x = 0
|
||||
y += shelf
|
||||
shelf = 0
|
||||
}
|
||||
const item = { i: widget.id, x, y, w: width, h: Math.max(1, h) }
|
||||
x += width
|
||||
shelf = Math.max(shelf, item.h)
|
||||
return item
|
||||
})
|
||||
}
|
||||
|
||||
function layoutOf(widgets: WidgetDef[], columns: number): Layout {
|
||||
if (!isPlaced(widgets)) return packed(widgets, columns)
|
||||
return widgets.map((widget) => {
|
||||
const { x = 0, y = 0, w = 3, h = 2 } = placement(widget)
|
||||
const width = Math.min(columns, Math.max(1, w))
|
||||
return {
|
||||
i: widget.id,
|
||||
x: Math.min(columns - width, Math.max(0, x)),
|
||||
y: Math.max(0, y),
|
||||
w: width,
|
||||
h: Math.max(1, h),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const same = (a: Layout, b: Layout) =>
|
||||
a.length === b.length &&
|
||||
a.every((item, index) => {
|
||||
const other = b[index]
|
||||
return (
|
||||
other &&
|
||||
item.i === other.i &&
|
||||
item.x === other.x &&
|
||||
item.y === other.y &&
|
||||
item.w === other.w &&
|
||||
item.h === other.h
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* A dashboard, viewed or edited, over the same dotted canvas the flows use.
|
||||
*
|
||||
* View mode never mounts the grid library: a wall panel that only displays
|
||||
* should not pay for the code that lets someone drag things around.
|
||||
*/
|
||||
export function DashboardEditor({
|
||||
dashboard,
|
||||
pageId,
|
||||
edit,
|
||||
}: {
|
||||
dashboard: DashboardDef_Output
|
||||
pageId?: string
|
||||
dashboard: Dashboard
|
||||
edit: boolean
|
||||
}) {
|
||||
const [draft, setDraft] = useState<DashboardDef_Output>(dashboard)
|
||||
const [draft, setDraft] = useState<Dashboard>(dashboard)
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [pageId, setPageId] = useState<string | undefined>(
|
||||
() => pagesOf(dashboard)[0]?.id,
|
||||
)
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const save = useSaveDashboard(dashboard.name)
|
||||
const { showErrorToast } = useCustomToast()
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
@@ -73,7 +173,7 @@ export function DashboardEditor({
|
||||
version.current = dashboard.version
|
||||
}, [dashboard.version])
|
||||
|
||||
const commit = (next: DashboardDef_Output) => {
|
||||
const commit = (next: Dashboard) => {
|
||||
setDraft(next)
|
||||
if (timer.current) clearTimeout(timer.current)
|
||||
timer.current = setTimeout(() => {
|
||||
@@ -90,14 +190,24 @@ export function DashboardEditor({
|
||||
}, AUTOSAVE_MS)
|
||||
}
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: () => DashboardsService.deleteDashboard({ name: draft.name }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: dashboardKeys.all })
|
||||
navigate({ to: "/dashboards" })
|
||||
},
|
||||
onError: handleError.bind(showErrorToast),
|
||||
})
|
||||
|
||||
const columns = columnsOf(draft)
|
||||
const pages = pagesOf(draft)
|
||||
const page = pages.find((candidate) => candidate.id === pageId) ?? pages[0]
|
||||
if (!page) return null
|
||||
const section = sectionsOf(page)[0]
|
||||
if (!section) return null
|
||||
const widgets = widgetsOf(section)
|
||||
const section = page ? sectionsOf(page)[0] : undefined
|
||||
const widgets = section ? widgetsOf(section) : []
|
||||
const { width, containerRef, mounted } = useContainerWidth()
|
||||
|
||||
const updateWidgets = (next: WidgetDef[]) =>
|
||||
const updateWidgets = (next: WidgetDef[]) => {
|
||||
if (!page || !section) return
|
||||
commit({
|
||||
...draft,
|
||||
pages: pagesOf(draft).map((candidate) =>
|
||||
@@ -113,19 +223,25 @@ export function DashboardEditor({
|
||||
},
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
const addWidget = (type: WidgetKind) => {
|
||||
const id = nextId(draft, type)
|
||||
const bottom = layoutOf(widgets, columns).reduce(
|
||||
(lowest, item) => Math.max(lowest, item.y + item.h),
|
||||
0,
|
||||
)
|
||||
updateWidgets([
|
||||
...widgets,
|
||||
{
|
||||
id,
|
||||
type,
|
||||
title: WIDGET_LABELS[type],
|
||||
layout: { lg: { x: 0, y: 0, ...WIDGET_SIZES[type] } },
|
||||
config: {},
|
||||
layout: { lg: { x: 0, y: bottom, ...WIDGET_SIZES[type] } },
|
||||
config: type === "chart" ? { series: [{}] } : {},
|
||||
},
|
||||
])
|
||||
setSettingsOpen(false)
|
||||
setSelected(id)
|
||||
}
|
||||
|
||||
@@ -136,238 +252,276 @@ export function DashboardEditor({
|
||||
),
|
||||
)
|
||||
|
||||
const resize = (widget: WidgetDef, by: number) => {
|
||||
const layout = (widget.layout ?? {}) as Record<
|
||||
string,
|
||||
{ x?: number; y?: number; w?: number; h?: number }
|
||||
>
|
||||
const current = layout.lg ?? { x: 0, y: 0, w: 3, h: 2 }
|
||||
patch(widget.id, {
|
||||
layout: {
|
||||
...layout,
|
||||
lg: { ...current, w: Math.min(12, Math.max(2, (current.w ?? 3) + by)) },
|
||||
},
|
||||
const layout = layoutOf(widgets, columns)
|
||||
|
||||
/** Store what the grid ended up doing, unless it did nothing. */
|
||||
const applyLayout = (next: Layout) => {
|
||||
if (same(layout, next)) return
|
||||
const byId = new Map(next.map((item) => [item.i, item]))
|
||||
updateWidgets(
|
||||
widgets.map((widget) => {
|
||||
const item = byId.get(widget.id)
|
||||
if (!item) return widget
|
||||
const placed: Placement = {
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
w: item.w,
|
||||
h: item.h,
|
||||
}
|
||||
return {
|
||||
...widget,
|
||||
layout: { ...(widget.layout ?? {}), lg: placed },
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const active = widgets.find((widget) => widget.id === selected) ?? null
|
||||
const panelOpen = Boolean(active) || settingsOpen
|
||||
|
||||
const setEdit = (next: boolean) => {
|
||||
setSelected(null)
|
||||
setSettingsOpen(false)
|
||||
navigate({
|
||||
to: "/dashboards/$name",
|
||||
params: { name: draft.name },
|
||||
search: next ? { edit: true } : {},
|
||||
})
|
||||
}
|
||||
|
||||
const active = widgets.find((widget) => widget.id === selected)
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Add</span>
|
||||
{KINDS.map((kind) => (
|
||||
<Button
|
||||
key={kind}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
data-testid={`add-widget-${kind}`}
|
||||
onClick={() => addWidget(kind)}
|
||||
>
|
||||
<Plus />
|
||||
{WIDGET_LABELS[kind]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<SectionGrid
|
||||
section={section}
|
||||
dashboard={draft.name}
|
||||
renderWidget={(widget) => (
|
||||
<WidgetFrame
|
||||
title={widget.title}
|
||||
className={
|
||||
widget.id === selected ? "ring-2 ring-primary" : undefined
|
||||
}
|
||||
actions={
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
aria-label="Narrower"
|
||||
onClick={() => resize(widget, -1)}
|
||||
>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
aria-label="Wider"
|
||||
onClick={() => resize(widget, 1)}
|
||||
>
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
aria-label="Remove widget"
|
||||
onClick={() =>
|
||||
updateWidgets(
|
||||
widgets.filter((other) => other.id !== widget.id),
|
||||
)
|
||||
const body = !page ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This dashboard has no pages yet.
|
||||
</p>
|
||||
) : !edit ? (
|
||||
<DashboardView dashboard={draft} pageId={page.id} />
|
||||
) : widgets.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground" data-testid="dashboard-empty">
|
||||
Nothing on this page yet. Add a widget from the bar below.
|
||||
</p>
|
||||
) : (
|
||||
<div ref={containerRef}>
|
||||
{/* Measured first: laying out against a guessed width would place every
|
||||
widget once and then move it. */}
|
||||
{mounted ? (
|
||||
<GridLayout
|
||||
width={width}
|
||||
layout={layout}
|
||||
onLayoutChange={applyLayout}
|
||||
gridConfig={{
|
||||
cols: columns,
|
||||
rowHeight: ROW_HEIGHT,
|
||||
margin: [GRID_GAP, GRID_GAP],
|
||||
containerPadding: [0, 0],
|
||||
}}
|
||||
// Only the header moves a widget, so a slider under the cursor still
|
||||
// slides and a switch still flips while the dashboard is being edited.
|
||||
dragConfig={{ handle: ".widget-grip" }}
|
||||
resizeConfig={{ handles: ["e", "s", "se"] }}
|
||||
>
|
||||
{widgets.map((widget) => (
|
||||
<div key={widget.id}>
|
||||
<WidgetFrame
|
||||
title={widget.title}
|
||||
issue={widgetIssue(widget)}
|
||||
className={cn(
|
||||
"cursor-pointer",
|
||||
widget.id === selected && "ring-2 ring-primary",
|
||||
)}
|
||||
grip
|
||||
onClick={(event) => {
|
||||
if (!(event.target as Element).closest(INTERACTIVE)) {
|
||||
setSettingsOpen(false)
|
||||
setSelected(widget.id)
|
||||
}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="min-h-0 flex-1 text-left"
|
||||
onClick={() => setSelected(widget.id)}
|
||||
>
|
||||
<WidgetBody widget={widget} dashboard={draft.name} />
|
||||
</button>
|
||||
</WidgetFrame>
|
||||
)}
|
||||
/>
|
||||
|
||||
{active ? (
|
||||
<WidgetSettings
|
||||
widget={active}
|
||||
onChange={(changes) => patch(active.id, changes)}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Add a widget, or pick one to configure it.
|
||||
</p>
|
||||
)}
|
||||
}}
|
||||
>
|
||||
<WidgetBody widget={widget} dashboard={draft.name} />
|
||||
</WidgetFrame>
|
||||
</div>
|
||||
))}
|
||||
</GridLayout>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** What one widget shows or does. Fields differ per type; the set is small. */
|
||||
function WidgetSettings({
|
||||
widget,
|
||||
onChange,
|
||||
}: {
|
||||
widget: WidgetDef
|
||||
onChange: (changes: Partial<WidgetDef>) => void
|
||||
}) {
|
||||
const { data: catalog } = useQuery(messageCatalogQueryOptions())
|
||||
const messages = catalog?.data ?? []
|
||||
const config = (widget.config ?? {}) as Record<string, unknown>
|
||||
const isInput = INPUT_WIDGETS.has(widget.type)
|
||||
const numericOnly = widget.type === "gauge" || widget.type === "chart"
|
||||
|
||||
const set = (key: string, value: unknown) =>
|
||||
onChange({ config: { ...config, [key]: value } })
|
||||
|
||||
const choices = messages.filter((message) =>
|
||||
numericOnly ? message.numeric : true,
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="grid gap-3 rounded-lg border border-border bg-card p-4 shadow-e1"
|
||||
data-testid="widget-settings"
|
||||
>
|
||||
<span className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
||||
{WIDGET_LABELS[widget.type]} settings
|
||||
</span>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm font-normal">Title</Label>
|
||||
<Input
|
||||
value={widget.title ?? ""}
|
||||
onChange={(event) => onChange({ title: event.target.value })}
|
||||
/>
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"dot-canvas absolute inset-0 overflow-y-auto px-4 pb-24 pt-20 transition-[padding] duration-200",
|
||||
panelOpen && "md:pr-[27rem]",
|
||||
)}
|
||||
data-testid="dashboard-canvas"
|
||||
>
|
||||
{body}
|
||||
</div>
|
||||
|
||||
{widget.type === "markdown" ? (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm font-normal">Text</Label>
|
||||
<Input
|
||||
value={String(config.content ?? "")}
|
||||
placeholder="# Heading"
|
||||
onChange={(event) => set("content", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm font-normal">
|
||||
{isInput ? "Publishes to" : "Shows"}
|
||||
</Label>
|
||||
<Select
|
||||
value={String(config[isInput ? "target" : "message"] ?? "")}
|
||||
onValueChange={(value) =>
|
||||
set(isInput ? "target" : "message", value)
|
||||
}
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 transition-[right] duration-200",
|
||||
panelOpen && "md:right-[27rem]",
|
||||
)}
|
||||
>
|
||||
<CanvasTitle>
|
||||
<span className="truncate px-3 py-1.5 text-sm font-medium">
|
||||
{draft.title || draft.name}
|
||||
</span>
|
||||
{pages.length > 1 ? (
|
||||
<Tabs value={page?.id} onValueChange={setPageId}>
|
||||
<TabsList>
|
||||
{pages.map((candidate) => (
|
||||
<TabsTrigger key={candidate.id} value={candidate.id}>
|
||||
{candidate.title || candidate.id}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
) : null}
|
||||
</CanvasTitle>
|
||||
|
||||
<motion.div
|
||||
variants={slideUp}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
transition={transitions.emphasized}
|
||||
className="pointer-events-auto absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1 rounded-full border border-border bg-card/80 px-1.5 py-1 shadow-e2 backdrop-blur-md pb-[max(0.25rem,env(safe-area-inset-bottom))]"
|
||||
>
|
||||
{edit ? (
|
||||
<>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 text-muted-foreground md:size-8"
|
||||
aria-label="Add widget"
|
||||
data-testid="add-widget"
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="center" className="w-56 p-2">
|
||||
<p className="px-2 py-1.5 text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground">
|
||||
Add a widget
|
||||
</p>
|
||||
<div className="mt-1 grid gap-0.5">
|
||||
{KINDS.map((kind) => (
|
||||
<button
|
||||
key={kind}
|
||||
type="button"
|
||||
className="rounded-sm px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent/50"
|
||||
data-testid={`add-widget-${kind}`}
|
||||
onClick={() => addWidget(kind)}
|
||||
>
|
||||
{WIDGET_LABELS[kind]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 text-muted-foreground md:size-8"
|
||||
onClick={() => {
|
||||
setSelected(null)
|
||||
setSettingsOpen(true)
|
||||
}}
|
||||
aria-label="Dashboard settings"
|
||||
data-testid="edit-dashboard"
|
||||
>
|
||||
<Settings2 />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Dashboard settings</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center text-muted-foreground">
|
||||
{save.isPending ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Check className="size-3.5" />
|
||||
)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{save.isPending ? "Saving" : "All changes saved"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Separator orientation="vertical" className="mx-0.5 !h-5" />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 text-muted-foreground md:size-8"
|
||||
aria-label="Open the panel view"
|
||||
data-testid="open-view"
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href={`/view/${draft.name}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<ExternalLink />
|
||||
</a>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Open what a wall panel sees</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Button
|
||||
variant={edit ? "brand" : "secondary"}
|
||||
size="sm"
|
||||
className="h-11 gap-1.5 md:h-8"
|
||||
onClick={() => setEdit(!edit)}
|
||||
data-testid="toggle-edit"
|
||||
>
|
||||
<SelectTrigger data-testid="widget-message">
|
||||
<SelectValue placeholder="Pick a message" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{choices.map((message) => (
|
||||
<SelectItem key={message.name} value={message.name}>
|
||||
{message.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{edit ? (
|
||||
<Check className="size-4" />
|
||||
) : (
|
||||
<Pencil className="size-4" />
|
||||
)}
|
||||
{edit ? "Done" : "Edit"}
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{(widget.type === "stat" || widget.type === "gauge") && (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm font-normal">Unit</Label>
|
||||
<Input
|
||||
value={String(config.unit ?? "")}
|
||||
placeholder="°C"
|
||||
onChange={(event) => set("unit", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(widget.type === "gauge" || widget.type === "slider") && (
|
||||
<div className="flex gap-2">
|
||||
<div className="grid flex-1 gap-1.5">
|
||||
<Label className="text-sm font-normal">Minimum</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={String(config.min ?? 0)}
|
||||
onChange={(event) => set("min", Number(event.target.value) || 0)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid flex-1 gap-1.5">
|
||||
<Label className="text-sm font-normal">Maximum</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={String(config.max ?? 100)}
|
||||
onChange={(event) => set("max", Number(event.target.value) || 0)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{widget.type === "button" && (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm font-normal">Sends</Label>
|
||||
<Input
|
||||
value={String(config.value ?? "")}
|
||||
placeholder="true"
|
||||
onChange={(event) => {
|
||||
const raw = event.target.value
|
||||
const asNumber = Number(raw)
|
||||
set(
|
||||
"value",
|
||||
raw === "true" || raw === "false"
|
||||
? raw === "true"
|
||||
: raw !== "" && Number.isFinite(asNumber)
|
||||
? asNumber
|
||||
: raw,
|
||||
)
|
||||
{edit ? (
|
||||
<>
|
||||
<WidgetPanel
|
||||
widget={active}
|
||||
onChange={(changes) => active && patch(active.id, changes)}
|
||||
onDelete={() => {
|
||||
if (!active) return
|
||||
updateWidgets(widgets.filter((other) => other.id !== active.id))
|
||||
setSelected(null)
|
||||
}}
|
||||
onClose={() => setSelected(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DashboardPanel
|
||||
open={settingsOpen}
|
||||
dashboard={draft}
|
||||
widgetCount={widgets.length}
|
||||
onChange={(changes) => commit({ ...draft, ...changes })}
|
||||
onDelete={() => remove.mutate()}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export { widgetStyle }
|
||||
|
||||
Reference in New Issue
Block a user