The phone path draws through DashboardView in both modes, so a locked dashboard came out with dead controls while it was being arranged — the canvas path hands editing to its own grid and never mounts the lock. DashboardView takes `editing` and skips LockedProvider for it; reading a locked dashboard stacked still locks. Guarded in mobile.spec.ts.
839 lines
29 KiB
TypeScript
839 lines
29 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
|
import { useNavigate } from "@tanstack/react-router"
|
|
import { Check, ExternalLink, Pencil, Plus, Settings2, X } from "lucide-react"
|
|
import { motion } from "motion/react"
|
|
import { type CSSProperties, useEffect, useRef, useState } from "react"
|
|
import {
|
|
GridLayout,
|
|
type Layout,
|
|
type Position,
|
|
setTransform,
|
|
} 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 {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog"
|
|
import { FluksioLoader } from "@/components/ui/fluksio-loader"
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover"
|
|
import { Separator } from "@/components/ui/separator"
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip"
|
|
import useCustomToast from "@/hooks/useCustomToast"
|
|
import { useIsMobile } from "@/hooks/useMobile"
|
|
import { slideUp, transitions } from "@/lib/motion"
|
|
import { appPath } from "@/lib/portal"
|
|
import { cn } from "@/lib/utils"
|
|
import { handleError } from "@/utils"
|
|
import {
|
|
areaOf,
|
|
CanvasSurface,
|
|
columnsOf,
|
|
type Dashboard,
|
|
DashboardView,
|
|
GRID_GAP,
|
|
isPlaced,
|
|
placement,
|
|
rowHeightOf,
|
|
rowsOf,
|
|
widgetsOf,
|
|
} from "./DashboardView"
|
|
import { PanelRail } from "./PanelRail"
|
|
import { LockNotice } from "./PanelSurface"
|
|
import { DashboardPanel, WidgetPanel } from "./panels"
|
|
import {
|
|
dashboardKeys,
|
|
panelsQueryOptions,
|
|
useDiscardDashboardDraft,
|
|
usePublishDashboard,
|
|
useSaveDashboard,
|
|
} from "./queries"
|
|
import { PaletteProvider } from "./settings"
|
|
import { useUi } from "./ui"
|
|
import { showTitle } from "./ui/core/config"
|
|
import { LookProvider, useCanvasRoot } from "./ui/core/look"
|
|
import {
|
|
WIDGET_LABELS,
|
|
WIDGET_SIZES,
|
|
WidgetBody,
|
|
type WidgetKind,
|
|
widgetIssue,
|
|
} from "./widgets"
|
|
|
|
const KINDS = Object.keys(WIDGET_LABELS) as WidgetKind[]
|
|
|
|
/**
|
|
* A widget as the editor draws it: the panel's own look, plus a click that
|
|
* picks it.
|
|
*
|
|
* Its own component because it draws with the look's components, which are
|
|
* only knowable below `LookProvider`.
|
|
*/
|
|
function Pickable({
|
|
widget,
|
|
dashboard,
|
|
grip,
|
|
selected,
|
|
onPick,
|
|
}: {
|
|
widget: WidgetDef
|
|
dashboard: string
|
|
grip: boolean
|
|
selected: boolean
|
|
onPick: () => void
|
|
}) {
|
|
const { Frame } = useUi()
|
|
return (
|
|
<Frame
|
|
title={showTitle(widget) ? widget.title : undefined}
|
|
issue={widgetIssue(widget)}
|
|
grip={grip}
|
|
selected={selected}
|
|
onClick={(event) => {
|
|
if (!(event.target as Element).closest(INTERACTIVE)) onPick()
|
|
}}
|
|
>
|
|
<WidgetBody widget={widget} dashboard={dashboard} />
|
|
</Frame>
|
|
)
|
|
}
|
|
|
|
/** 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.
|
|
*
|
|
* The drag handle counts too: a press on the grip is a move, not a pick, and
|
|
* the mouseup ending a drag still fires a click on the frame. Selecting there
|
|
* would open the settings panel — and the canvas lurches while it animates —
|
|
* every time a widget is dropped. The body is what picks it instead, which is
|
|
* how the resize handle has always behaved. On a phone the grip class is never
|
|
* applied, so the header keeps selecting: there is no dragging to confuse it
|
|
* with.
|
|
*/
|
|
const INTERACTIVE =
|
|
"button, a, input, select, textarea, [role='switch'], [role='combobox'], [role='slider'], .react-resizable-handle, .widget-grip"
|
|
|
|
/**
|
|
* What a click may land on without counting as a click off the widgets: the
|
|
* widget itself, and the handles the grid draws around it — resizing a tile is
|
|
* still working on it. The grid item covers both; the frame is what a stacked
|
|
* dashboard has instead.
|
|
*/
|
|
const ON_WIDGET = "[data-testid=widget-frame], .react-grid-item"
|
|
|
|
function nextId(dashboard: DashboardDef_Output, type: string): string {
|
|
const taken = new Set(widgetsOf(dashboard).map((widget) => widget.id))
|
|
let candidate = type
|
|
for (let i = 2; taken.has(candidate); i++) candidate = `${type}${i}`
|
|
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),
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* How the grid positions and drags items on a CSS-scaled surface.
|
|
*
|
|
* The library's own `createScaledStrategy` divides the drag delta by the scale
|
|
* but drops the container offset while doing it, so a widget jumps on pick-up.
|
|
* Leaving `calcDragPosition` unset keeps its parent-relative maths, which
|
|
* already divides by `scale` — the only thing that needed saying.
|
|
*/
|
|
const scaledStrategy = (scale: number) => ({
|
|
type: "transform" as const,
|
|
scale,
|
|
calcStyle: setTransform as (pos: Position) => CSSProperties,
|
|
})
|
|
|
|
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. Nor does a
|
|
* phone — arranging is not a phone feature, and `applyLayout` below writes
|
|
* whatever the grid reports, so a stacked layout reaching it would overwrite
|
|
* the arrangement the panel is meant to show.
|
|
*/
|
|
export function DashboardEditor({
|
|
dashboard,
|
|
edit,
|
|
}: {
|
|
dashboard: Dashboard
|
|
edit: boolean
|
|
}) {
|
|
const [draft, setDraft] = useState<Dashboard>(dashboard)
|
|
const [selected, setSelected] = useState<string | null>(null)
|
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
|
// A phone reads the dashboard rather than arranges it, so the grid library
|
|
// never mounts there. See DESIGN-GUIDELINES.md → Responsive.
|
|
const stacked = useIsMobile()
|
|
// The dashboard's own appearance, on the surface only: the shell around the
|
|
// canvas stays whatever the person editing chose for the app. Non-stacked,
|
|
// `CanvasSurface` states it on the canvas box itself.
|
|
const root = useCanvasRoot(draft)
|
|
const navigate = useNavigate()
|
|
const queryClient = useQueryClient()
|
|
const save = useSaveDashboard(dashboard.name)
|
|
const publish = usePublishDashboard(dashboard.name)
|
|
const discard = useDiscardDashboardDraft(dashboard.name)
|
|
const [discardOpen, setDiscardOpen] = useState(false)
|
|
const { showErrorToast } = useCustomToast()
|
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
// The save that is already on its way, so a publish waits for it instead of
|
|
// going out with the version it is about to replace.
|
|
const inflight = useRef<Promise<void> | null>(null)
|
|
// The saved version is what the next save is based on; without following it
|
|
// the second save of a session is always a conflict.
|
|
const version = useRef(dashboard.version)
|
|
|
|
useEffect(() => {
|
|
version.current = dashboard.version
|
|
}, [dashboard.version])
|
|
|
|
const store = (next: Dashboard) => {
|
|
const request = save
|
|
.mutateAsync({ ...next, version: version.current })
|
|
.then((saved) => {
|
|
version.current = saved.version
|
|
})
|
|
.catch((error) => handleError.call(showErrorToast, error as ApiError))
|
|
inflight.current = request
|
|
return request
|
|
}
|
|
|
|
const commit = (next: Dashboard) => {
|
|
setDraft(next)
|
|
if (timer.current) clearTimeout(timer.current)
|
|
timer.current = setTimeout(() => {
|
|
timer.current = null
|
|
void store(next)
|
|
}, AUTOSAVE_MS)
|
|
}
|
|
|
|
/** Send what is queued and wait for the server to have it. */
|
|
const flush = async () => {
|
|
if (timer.current) {
|
|
clearTimeout(timer.current)
|
|
timer.current = null
|
|
await store(draft)
|
|
return
|
|
}
|
|
await inflight.current
|
|
}
|
|
|
|
/** Put the stored draft live. Publishing what is queued means saving first. */
|
|
const publishDashboard = async () => {
|
|
await flush()
|
|
publish.mutate(version.current ?? 1)
|
|
}
|
|
|
|
const remove = useMutation({
|
|
mutationFn: () => DashboardsService.deleteDashboard({ name: draft.name }),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: dashboardKeys.all })
|
|
navigate({ to: "/dashboards" })
|
|
},
|
|
onError: handleError.bind(showErrorToast),
|
|
})
|
|
|
|
// What the server says about the stored document, not about the local edit:
|
|
// a save in flight is still "no unpublished changes" until it lands.
|
|
const hasDraft = Boolean(dashboard.has_draft)
|
|
const columns = columnsOf(draft)
|
|
const widgets = widgetsOf(draft)
|
|
|
|
const updateWidgets = (next: WidgetDef[]) => {
|
|
commit({ ...draft, widgets: next })
|
|
}
|
|
|
|
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: bottom, ...WIDGET_SIZES[type] } },
|
|
config: type === "chart" ? { series: [{}] } : {},
|
|
},
|
|
])
|
|
setSettingsOpen(false)
|
|
setSelected(id)
|
|
}
|
|
|
|
const patch = (id: string, changes: Partial<WidgetDef>) =>
|
|
updateWidgets(
|
|
widgets.map((widget) =>
|
|
widget.id === id ? { ...widget, ...changes } : widget,
|
|
),
|
|
)
|
|
|
|
// A dashboard hanging on a panel beside others is drawn with a rail on it,
|
|
// which takes room off the canvas. Show that here rather than letting someone
|
|
// arrange against a width the wall does not have.
|
|
// ponytail: the first such panel wins — the others differ only in scale.
|
|
const { data: panels } = useQuery(panelsQueryOptions())
|
|
const railDashboards =
|
|
(panels?.panels ?? []).find(
|
|
(panel) =>
|
|
(panel.dashboards ?? []).length > 1 &&
|
|
(panel.dashboards ?? []).includes(draft.name),
|
|
)?.dashboards ?? null
|
|
const hasRail = Boolean(railDashboards) && !stacked
|
|
// What the arrangement is laid out in: the canvas less its margin, and less
|
|
// the rail's column when the panel carries one.
|
|
const area = areaOf(draft, hasRail)
|
|
|
|
const layout = layoutOf(widgets, columns)
|
|
const rows = rowsOf(draft, hasRail)
|
|
|
|
// maxRows below only constrains a *drag*. A stored placement is corrected
|
|
// against `cols` and nothing else, so a canvas that shrank leaves whatever
|
|
// now falls past its bottom edge drawn under the clip.
|
|
const clipped = layout.filter((item) => item.y + item.h > rows)
|
|
|
|
/** 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 },
|
|
}
|
|
}),
|
|
)
|
|
}
|
|
|
|
/** Shelf-pack in reading order, which is the only reflow that can help:
|
|
* the grid already compacts vertically, so nothing below the canvas has
|
|
* room above it — it has to move sideways. */
|
|
const reflow = () => {
|
|
const at = new Map(layout.map((item) => [item.i, item]))
|
|
const ordered = [...widgets].sort((a, b) => {
|
|
const left = at.get(a.id)
|
|
const right = at.get(b.id)
|
|
return (
|
|
(left?.y ?? 0) - (right?.y ?? 0) || (left?.x ?? 0) - (right?.x ?? 0)
|
|
)
|
|
})
|
|
applyLayout(packed(ordered, columns))
|
|
}
|
|
|
|
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 } : {},
|
|
})
|
|
}
|
|
|
|
/** A widget that can be picked to open its settings. */
|
|
const pickable = (widget: WidgetDef, grip = false) => (
|
|
<Pickable
|
|
widget={widget}
|
|
dashboard={draft.name}
|
|
grip={grip}
|
|
selected={widget.id === selected}
|
|
onPick={() => {
|
|
setSettingsOpen(false)
|
|
setSelected(widget.id)
|
|
}}
|
|
/>
|
|
)
|
|
|
|
const body =
|
|
edit && widgets.length === 0 ? (
|
|
<p
|
|
className="text-sm text-muted-foreground"
|
|
data-testid="dashboard-empty"
|
|
>
|
|
Nothing on this dashboard yet. Add a widget from the bar below.
|
|
</p>
|
|
) : stacked ? (
|
|
// One column at the viewport's width. Edit mode still picks a widget and
|
|
// opens its settings; only the arrangement is missing.
|
|
<div
|
|
data-look={root["data-look"]}
|
|
data-touch={root["data-touch"]}
|
|
data-palette={root["data-palette"]}
|
|
className={cn(
|
|
"h-full overflow-y-auto bg-background text-foreground",
|
|
root.className,
|
|
)}
|
|
style={root.style}
|
|
>
|
|
<DashboardView
|
|
dashboard={draft}
|
|
stacked
|
|
editing={edit}
|
|
renderWidget={edit ? pickable : undefined}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<CanvasSurface
|
|
dashboard={draft}
|
|
dots={edit}
|
|
rail={
|
|
railDashboards ? (
|
|
<PanelRail
|
|
dashboards={railDashboards}
|
|
current={draft.name}
|
|
// Editing one dashboard of a panel and editing its neighbour are
|
|
// the same job, so the rail keeps whichever mode this one is in.
|
|
linkFor={(name) => ({
|
|
to: "/dashboards/$name",
|
|
params: { name },
|
|
search: edit ? { edit: true } : {},
|
|
})}
|
|
/>
|
|
) : null
|
|
}
|
|
>
|
|
{(scale) =>
|
|
!edit ? (
|
|
<DashboardView dashboard={draft} rail={hasRail} />
|
|
) : (
|
|
<GridLayout
|
|
width={area.width}
|
|
layout={layout}
|
|
onLayoutChange={applyLayout}
|
|
positionStrategy={scaledStrategy(scale)}
|
|
gridConfig={{
|
|
cols: columns,
|
|
rowHeight: rowHeightOf(draft, hasRail),
|
|
margin: [GRID_GAP, GRID_GAP],
|
|
containerPadding: [0, 0],
|
|
// The canvas is the constraint: nothing may be dragged off the
|
|
// panel it is being drawn for. Shared with the warning above, so
|
|
// the constraint and what it is warned about cannot disagree.
|
|
maxRows: rows,
|
|
}}
|
|
// 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.
|
|
// The header is the handle, and a chart now draws its range
|
|
// picker there — so that one control is exempt, or picking a
|
|
// window would drag the tile instead.
|
|
dragConfig={{
|
|
handle: ".widget-grip",
|
|
cancel: "[data-testid=range-picker]",
|
|
}}
|
|
resizeConfig={{ handles: ["e", "s", "se"] }}
|
|
>
|
|
{widgets.map((widget) => (
|
|
<div key={widget.id}>{pickable(widget, true)}</div>
|
|
))}
|
|
</GridLayout>
|
|
)
|
|
}
|
|
</CanvasSurface>
|
|
)
|
|
|
|
return (
|
|
<LookProvider dashboard={draft}>
|
|
{/* Clicking off the widgets is how you put a panel away, as it is on the
|
|
flow pane — the dashboard is what you went back to look at. A menu a
|
|
widget portals to `body` lands outside this element but still bubbles
|
|
through React's tree, so only a press that landed in the subtree
|
|
counts. */}
|
|
{/* biome-ignore lint/a11y/useKeyWithClickEvents: Escape closes the panel; this is a pointer shortcut, not a control. */}
|
|
{/* biome-ignore lint/a11y/noStaticElementInteractions: see above. */}
|
|
<div
|
|
className={cn(
|
|
"absolute inset-0 overflow-hidden px-4 pb-24 pt-20 transition-[padding] duration-200",
|
|
panelOpen && "md:pr-[27rem]",
|
|
)}
|
|
data-testid="dashboard-canvas"
|
|
onClick={(event) => {
|
|
const target = event.target as Element
|
|
if (!event.currentTarget.contains(target)) return
|
|
if (target.closest(ON_WIDGET)) return
|
|
setSelected(null)
|
|
setSettingsOpen(false)
|
|
}}
|
|
>
|
|
<PaletteProvider dashboard={draft}>{body}</PaletteProvider>
|
|
</div>
|
|
|
|
<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>
|
|
</CanvasTitle>
|
|
|
|
{/* Reading this dashboard is reading the panel, disabled controls and
|
|
all — so it says the same thing a panel does. Not while editing:
|
|
arranging never mounts the lock, so the controls are live. */}
|
|
{edit ? null : <LockNotice dashboard={draft} />}
|
|
|
|
<motion.div
|
|
variants={slideUp}
|
|
initial="hidden"
|
|
animate="visible"
|
|
exit="exit"
|
|
transition={transitions.emphasized}
|
|
// Capped and wrapping, like the flow dock; see DESIGN-GUIDELINES.md
|
|
// → Responsive.
|
|
className="pointer-events-auto absolute bottom-4 left-1/2 z-10 flex max-w-[calc(100vw-2rem)] -translate-x-1/2 flex-wrap items-center justify-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 ? (
|
|
<>
|
|
{/* A canvas can be shrunk to less than what is arranged on it,
|
|
and the grid says nothing: the placement it corrects is
|
|
bounded by the columns alone. Say so, and offer the one
|
|
remedy — re-packing across the columns. Not an error, so no
|
|
destructive colour and no brand fill either; Done already
|
|
owns the one brand affordance here. A phone has no canvas to
|
|
fall off, so it never sees this. */}
|
|
{clipped.length > 0 && !stacked ? (
|
|
<motion.div
|
|
variants={slideUp}
|
|
initial="hidden"
|
|
animate="visible"
|
|
exit="exit"
|
|
transition={transitions.emphasized}
|
|
role="status"
|
|
aria-live="polite"
|
|
data-testid="canvas-clipped"
|
|
// A sibling of the buttons, positioned against the dock, so
|
|
// it clears however many rows the dock wrapped into.
|
|
className="absolute bottom-full left-1/2 mb-3 flex max-w-[calc(100vw-2rem)] -translate-x-1/2 items-center gap-2 rounded-full border border-border bg-card/80 py-1 pl-4 pr-1 shadow-e2 backdrop-blur-md"
|
|
>
|
|
<span className="truncate text-sm">
|
|
{clipped.length === 1
|
|
? "1 widget sits below the canvas — a panel this size will not show it."
|
|
: `${clipped.length} widgets sit below the canvas — a panel this size will not show them.`}
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-11 shrink-0 md:h-8"
|
|
onClick={reflow}
|
|
data-testid="reflow-canvas"
|
|
>
|
|
Reflow
|
|
</Button>
|
|
</motion.div>
|
|
) : null}
|
|
|
|
<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={cn(
|
|
"size-11 text-muted-foreground md:size-8",
|
|
settingsOpen && "text-primary",
|
|
)}
|
|
onClick={() => {
|
|
if (settingsOpen) {
|
|
setSettingsOpen(false)
|
|
return
|
|
}
|
|
setSelected(null)
|
|
setSettingsOpen(true)
|
|
}}
|
|
aria-pressed={settingsOpen}
|
|
aria-label="Dashboard settings"
|
|
data-testid="edit-dashboard"
|
|
>
|
|
<Settings2 />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Dashboard settings</TooltipContent>
|
|
</Tooltip>
|
|
|
|
{/* Only while there is something to throw away. */}
|
|
{hasDraft ? (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-11 text-muted-foreground md:size-8"
|
|
onClick={() => setDiscardOpen(true)}
|
|
aria-label="Discard the unpublished changes"
|
|
data-testid="discard-dashboard"
|
|
>
|
|
<X />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
Discard the unpublished changes
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
) : null}
|
|
|
|
{/* Saved state and publish are one control, as in the flow dock:
|
|
the glyph stays put and simply stops being pressable. */}
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-11 text-muted-foreground md:size-8"
|
|
onClick={() => void publishDashboard()}
|
|
disabled={
|
|
!hasDraft || publish.isPending || save.isPending
|
|
}
|
|
aria-label="Publish this dashboard"
|
|
data-testid="publish-dashboard"
|
|
>
|
|
{save.isPending || publish.isPending ? (
|
|
<FluksioLoader className="size-3.5" />
|
|
) : (
|
|
<Check className="size-3.5" />
|
|
)}
|
|
</Button>
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{publish.isPending
|
|
? "Publishing"
|
|
: save.isPending
|
|
? "Saving"
|
|
: hasDraft
|
|
? "Saved — publish to put it on the panels"
|
|
: "All changes saved"}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
|
|
<Separator
|
|
orientation="vertical"
|
|
className="mx-0.5 !h-5 hidden md:block"
|
|
/>
|
|
</>
|
|
) : 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={appPath(`/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"
|
|
>
|
|
{edit ? (
|
|
<Check className="size-4" />
|
|
) : (
|
|
<Pencil className="size-4" />
|
|
)}
|
|
{edit ? "Done" : "Edit"}
|
|
</Button>
|
|
</motion.div>
|
|
</div>
|
|
|
|
{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)}
|
|
/>
|
|
|
|
<DashboardPanel
|
|
open={settingsOpen}
|
|
dashboard={draft}
|
|
widgetCount={widgets.length}
|
|
onChange={(changes) => commit({ ...draft, ...changes })}
|
|
onDelete={() => remove.mutate()}
|
|
onClose={() => setSettingsOpen(false)}
|
|
/>
|
|
|
|
<Dialog open={discardOpen} onOpenChange={setDiscardOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Discard the unpublished changes?</DialogTitle>
|
|
<DialogDescription>
|
|
The dashboard goes back to what the panels are showing. What
|
|
you edited since is dropped.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setDiscardOpen(false)}>
|
|
Keep editing
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
disabled={discard.isPending}
|
|
onClick={() => {
|
|
setDiscardOpen(false)
|
|
// Leaving edit mode remounts the editor, which is how the
|
|
// published document replaces the working copy.
|
|
discard.mutate(undefined, {
|
|
onSuccess: () => setEdit(false),
|
|
})
|
|
}}
|
|
data-testid="confirm-discard-dashboard"
|
|
>
|
|
Discard changes
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
) : null}
|
|
</LookProvider>
|
|
)
|
|
}
|