diff --git a/frontend/src/components/Common/Dock.tsx b/frontend/src/components/Common/Dock.tsx
new file mode 100644
index 0000000..ae2c505
--- /dev/null
+++ b/frontend/src/components/Common/Dock.tsx
@@ -0,0 +1,196 @@
+import { Check, WifiOff, X } from "lucide-react"
+import { motion } from "motion/react"
+import type { ReactNode } from "react"
+
+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 { Separator } from "@/components/ui/separator"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import { slideUp, transitions } from "@/lib/motion"
+import { cn } from "@/lib/utils"
+
+/**
+ * The action bar floating at the bottom of an editor canvas, flow or dashboard.
+ *
+ * Capped and wrapping: the canvas shell clips, so an uncapped row would put the
+ * buttons at its ends out of reach on a phone rather than merely look wrong.
+ * See DESIGN-GUIDELINES.md → Responsive.
+ */
+export function Dock({
+ className,
+ children,
+}: {
+ className?: string
+ children: ReactNode
+}) {
+ return (
+
+ {children}
+
+ )
+}
+
+/** Groups the dock's buttons; a phone wraps instead. */
+export function DockSeparator() {
+ return (
+
+ )
+}
+
+/**
+ * Saved state and publish are one control: the glyph never moves, it simply
+ * stops being something you can press once there is nothing left to put live.
+ * A button that appears and disappears moved everything beside it just as the
+ * work was finished.
+ */
+export function PublishButton({
+ label,
+ testId,
+ hasDraft,
+ saving,
+ publishing,
+ offline = false,
+ draftHint,
+ onPublish,
+}: {
+ label: string
+ testId: string
+ hasDraft: boolean
+ saving: boolean
+ publishing: boolean
+ /** The engine is out of reach, so there is nothing to publish to. */
+ offline?: boolean
+ /** What the tooltip says while there is a saved draft waiting. */
+ draftHint: string
+ onPublish: () => void
+}) {
+ return (
+
+
+
+
+
+
+
+ {offline
+ ? "Reconnecting to the engine"
+ : publishing
+ ? "Publishing"
+ : saving
+ ? "Saving"
+ : hasDraft
+ ? draftHint
+ : "All changes saved"}
+
+
+ )
+}
+
+/** Sits next to publish, and only while there is something to throw away. */
+export function DiscardButton({
+ testId,
+ onClick,
+}: {
+ testId: string
+ onClick: () => void
+}) {
+ return (
+
+
+
+
+ Discard the unpublished changes
+
+ )
+}
+
+/** The confirmation behind `DiscardButton`; `children` says what goes back. */
+export function DiscardDraftDialog({
+ open,
+ onOpenChange,
+ pending,
+ testId,
+ onConfirm,
+ children,
+}: {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ pending: boolean
+ testId: string
+ onConfirm: () => void
+ children: ReactNode
+}) {
+ return (
+
+ )
+}
diff --git a/frontend/src/components/Dashboard/DashboardEditor.tsx b/frontend/src/components/Dashboard/DashboardEditor.tsx
index 3c722e6..fbdf360 100644
--- a/frontend/src/components/Dashboard/DashboardEditor.tsx
+++ b/frontend/src/components/Dashboard/DashboardEditor.tsx
@@ -1,6 +1,6 @@
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 { Check, ExternalLink, Pencil, Plus, Settings2 } from "lucide-react"
import { motion } from "motion/react"
import { type CSSProperties, useEffect, useRef, useState } from "react"
import {
@@ -18,18 +18,15 @@ import {
type Placement,
type WidgetDef,
} from "@/client"
+import {
+ DiscardButton,
+ DiscardDraftDialog,
+ Dock,
+ DockSeparator,
+ PublishButton,
+} from "@/components/Common/Dock"
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 { Separator } from "@/components/ui/separator"
import {
Tooltip,
TooltipContent,
@@ -576,16 +573,7 @@ export function DashboardEditor({
arranging never mounts the lock, so the controls are live. */}
{edit ? null : }
-
+
{edit ? (
<>
{/* A canvas can be shrunk to less than what is arranged on it,
@@ -669,66 +657,24 @@ export function DashboardEditor({
Dashboard settings
- {/* Only while there is something to throw away. */}
{hasDraft ? (
-
-
-
-
-
- Discard the unpublished changes
-
-
+ setDiscardOpen(true)}
+ />
) : null}
- {/* Saved state and publish are one control, as in the flow dock:
- the glyph stays put and simply stops being pressable. */}
-
-
-
-
-
-
-
- {publish.isPending
- ? "Publishing"
- : save.isPending
- ? "Saving"
- : hasDraft
- ? "Saved — publish to put it on the panels"
- : "All changes saved"}
-
-
-
- void publishDashboard()}
/>
+
+
>
) : null}
@@ -767,7 +713,7 @@ export function DashboardEditor({
)}
{edit ? "Done" : "Edit"}
-
+
{edit ? (
@@ -798,37 +744,20 @@ export function DashboardEditor({
onClose={() => setSettingsOpen(false)}
/>
-
+
+ discard.mutate(undefined, { onSuccess: () => setEdit(false) })
+ }
+ >
+ The dashboard goes back to what the panels are showing. What you
+ edited since is dropped.
+
>
) : null}
diff --git a/frontend/src/components/Flow/FlowDock.tsx b/frontend/src/components/Flow/FlowDock.tsx
index 0d9d4fa..aff6e96 100644
--- a/frontend/src/components/Flow/FlowDock.tsx
+++ b/frontend/src/components/Flow/FlowDock.tsx
@@ -1,7 +1,6 @@
import { useReactFlow } from "@xyflow/react"
import {
AlertCircle,
- Check,
Maximize2,
Pause,
Pencil,
@@ -9,16 +8,19 @@ import {
PlayCircle,
Plus,
StepForward,
- WifiOff,
- X,
} from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import { useEffect, useState } from "react"
import type { ValidationIssue } from "@/client"
+import {
+ DiscardButton,
+ Dock,
+ DockSeparator,
+ PublishButton,
+} from "@/components/Common/Dock"
import { Button } from "@/components/ui/button"
import { FluksioLoader } from "@/components/ui/fluksio-loader"
-import { Separator } from "@/components/ui/separator"
import {
Tooltip,
TooltipContent,
@@ -133,17 +135,7 @@ export function FlowDock({
-
+
-
+
{/* A phone pinches to zoom and the graph fits itself, so this would only
be taking room the rest of the bar needs. */}
@@ -184,10 +173,7 @@ export function FlowDock({
{issues.length > 0 ? (
<>
-
+
) : null}
-
+
@@ -281,10 +264,7 @@ export function FlowDock({
-
+
@@ -302,67 +282,21 @@ export function FlowDock({
Flow settings
- {/* Throwing the edit away sits next to putting it live, and only exists
- while there is something to throw away. */}
{hasDraft ? (
-
-
-
-
- Discard the unpublished changes
-
+
) : null}
- {/*
- * Saved state and publish are one control: the glyph never moves, it
- * simply stops being something you can press once there is nothing left
- * to put live. A button that appears and disappears moved everything
- * beside it just as the work was finished.
- */}
-
-
-
-
-
-
-
- {!connected
- ? "Reconnecting to the engine"
- : publishing
- ? "Publishing"
- : saving
- ? "Saving"
- : hasDraft
- ? "Saved — publish to put it live"
- : "All changes saved"}
-
-
-
+
+
)
}
diff --git a/frontend/src/components/Flow/FlowEditor.tsx b/frontend/src/components/Flow/FlowEditor.tsx
index 692606e..4908223 100644
--- a/frontend/src/components/Flow/FlowEditor.tsx
+++ b/frontend/src/components/Flow/FlowEditor.tsx
@@ -32,6 +32,7 @@ import {
type MessageSpec,
type NodeDef_Input,
} from "@/client"
+import { DiscardDraftDialog } from "@/components/Common/Dock"
import { useTheme } from "@/components/theme-provider"
import { Button } from "@/components/ui/button"
import {
@@ -1462,41 +1463,25 @@ function FlowEditorInner({
-
+
+ discard.mutate(undefined, {
+ // The published document replaces what is on the canvas, and
+ // the version counter goes back with it.
+ onSuccess: () => {
+ setFlowPanelOpen(false)
+ onReload()
+ },
+ })
+ }
+ >
+ The canvas goes back to the version the engine is running. What you
+ edited since is dropped, though the flow store's git history keeps it.
+
{/*
* Not dismissable: until one version wins, every further save fails, so