Shared Dock, PublishButton, DiscardButton, DiscardDraftDialog for the flow and dashboard editors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 22:04:34 +02:00
co-authored by Claude Opus 5.5
parent 8d158169e5
commit 72b4cf3556
4 changed files with 277 additions and 233 deletions
+196
View File
@@ -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 (
<motion.div
variants={slideUp}
initial="hidden"
animate="visible"
exit="exit"
transition={transitions.emphasized}
className={cn(
"pointer-events-auto flex max-w-[calc(100vw-2rem)] 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))]",
className,
)}
>
{children}
</motion.div>
)
}
/** Groups the dock's buttons; a phone wraps instead. */
export function DockSeparator() {
return (
<Separator orientation="vertical" className="mx-0.5 !h-5 hidden md:block" />
)
}
/**
* 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 (
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="ghost"
size="icon-touch"
className="text-muted-foreground"
onClick={onPublish}
disabled={!hasDraft || publishing || saving || offline}
aria-label={label}
data-testid={testId}
>
{offline ? (
<WifiOff className="size-3.5" />
) : saving || publishing ? (
<FluksioLoader className="size-3.5" />
) : (
<Check className="size-3.5" />
)}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{offline
? "Reconnecting to the engine"
: publishing
? "Publishing"
: saving
? "Saving"
: hasDraft
? draftHint
: "All changes saved"}
</TooltipContent>
</Tooltip>
)
}
/** Sits next to publish, and only while there is something to throw away. */
export function DiscardButton({
testId,
onClick,
}: {
testId: string
onClick: () => void
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-touch"
className="text-muted-foreground"
onClick={onClick}
aria-label="Discard the unpublished changes"
data-testid={testId}
>
<X />
</Button>
</TooltipTrigger>
<TooltipContent>Discard the unpublished changes</TooltipContent>
</Tooltip>
)
}
/** 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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Discard the unpublished changes?</DialogTitle>
<DialogDescription>{children}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Keep editing
</Button>
<Button
variant="destructive"
disabled={pending}
onClick={() => {
onOpenChange(false)
onConfirm()
}}
data-testid={testId}
>
Discard changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -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 : <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))]"
>
<Dock className="absolute bottom-4 left-1/2 z-10 -translate-x-1/2">
{edit ? (
<>
{/* A canvas can be shrunk to less than what is arranged on it,
@@ -669,66 +657,24 @@ export function DashboardEditor({
<TooltipContent>Dashboard settings</TooltipContent>
</Tooltip>
{/* Only while there is something to throw away. */}
{hasDraft ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-touch"
className="text-muted-foreground"
onClick={() => setDiscardOpen(true)}
aria-label="Discard the unpublished changes"
data-testid="discard-dashboard"
>
<X />
</Button>
</TooltipTrigger>
<TooltipContent>
Discard the unpublished changes
</TooltipContent>
</Tooltip>
<DiscardButton
testId="discard-dashboard"
onClick={() => setDiscardOpen(true)}
/>
) : 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-touch"
className="text-muted-foreground"
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"
<PublishButton
label="Publish this dashboard"
testId="publish-dashboard"
hasDraft={hasDraft}
saving={save.isPending}
publishing={publish.isPending}
draftHint="Saved — publish to put it on the panels"
onPublish={() => void publishDashboard()}
/>
<DockSeparator />
</>
) : null}
@@ -767,7 +713,7 @@ export function DashboardEditor({
)}
{edit ? "Done" : "Edit"}
</Button>
</motion.div>
</Dock>
</div>
{edit ? (
@@ -798,37 +744,20 @@ export function DashboardEditor({
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>
<DiscardDraftDialog
open={discardOpen}
onOpenChange={setDiscardOpen}
pending={discard.isPending}
testId="confirm-discard-dashboard"
// Leaving edit mode remounts the editor, which is how the
// published document replaces the working copy.
onConfirm={() =>
discard.mutate(undefined, { onSuccess: () => setEdit(false) })
}
>
The dashboard goes back to what the panels are showing. What you
edited since is dropped.
</DiscardDraftDialog>
</>
) : null}
</LookProvider>
+23 -89
View File
@@ -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({
<LogsPanel flow={flow} {...logs} />
</div>
<motion.div
variants={slideUp}
initial="hidden"
animate="visible"
exit="exit"
transition={transitions.emphasized}
// 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.
className="pointer-events-auto flex max-w-[calc(100vw-2rem)] 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))]"
>
<Dock>
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -160,10 +152,7 @@ export function FlowDock({
<TooltipContent>Add a node (⌘P)</TooltipContent>
</Tooltip>
<Separator
orientation="vertical"
className="mx-0.5 !h-5 hidden md:block"
/>
<DockSeparator />
{/* 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 ? (
<>
<Separator
orientation="vertical"
className="mx-0.5 !h-5 hidden md:block"
/>
<DockSeparator />
<Button
variant="ghost"
size="sm-touch"
@@ -208,10 +194,7 @@ export function FlowDock({
</>
) : null}
<Separator
orientation="vertical"
className="mx-0.5 !h-5 hidden md:block"
/>
<DockSeparator />
<LogsTrigger open={logs.open} onOpenChange={logs.onOpenChange} />
@@ -281,10 +264,7 @@ export function FlowDock({
</TooltipContent>
</Tooltip>
<Separator
orientation="vertical"
className="mx-0.5 !h-5 hidden md:block"
/>
<DockSeparator />
<Tooltip>
<TooltipTrigger asChild>
@@ -302,67 +282,21 @@ export function FlowDock({
<TooltipContent>Flow settings</TooltipContent>
</Tooltip>
{/* Throwing the edit away sits next to putting it live, and only exists
while there is something to throw away. */}
{hasDraft ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-touch"
className="text-muted-foreground"
onClick={onDiscard}
aria-label="Discard the unpublished changes"
data-testid="discard-draft"
>
<X />
</Button>
</TooltipTrigger>
<TooltipContent>Discard the unpublished changes</TooltipContent>
</Tooltip>
<DiscardButton testId="discard-draft" onClick={onDiscard} />
) : 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.
*/}
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="ghost"
size="icon-touch"
className="text-muted-foreground"
onClick={onPublish}
disabled={!hasDraft || publishing || saving || !connected}
aria-label="Publish this flow"
data-testid="publish-flow"
>
{!connected ? (
<WifiOff className="size-3.5" />
) : saving || publishing ? (
<FluksioLoader className="size-3.5" />
) : (
<Check className="size-3.5" />
)}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{!connected
? "Reconnecting to the engine"
: publishing
? "Publishing"
: saving
? "Saving"
: hasDraft
? "Saved — publish to put it live"
: "All changes saved"}
</TooltipContent>
</Tooltip>
</motion.div>
<PublishButton
label="Publish this flow"
testId="publish-flow"
hasDraft={hasDraft}
saving={saving}
publishing={publishing}
offline={!connected}
draftHint="Saved — publish to put it live"
onPublish={onPublish}
/>
</Dock>
</div>
)
}
+20 -35
View File
@@ -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({
</DialogContent>
</Dialog>
<Dialog open={discardOpen} onOpenChange={setDiscardOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Discard the unpublished changes?</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDiscardOpen(false)}>
Keep editing
</Button>
<Button
variant="destructive"
disabled={discard.isPending}
onClick={() => {
setDiscardOpen(false)
discard.mutate(undefined, {
// The published document replaces what is on the canvas, and
// the version counter goes back with it.
onSuccess: () => {
setFlowPanelOpen(false)
onReload()
},
})
}}
data-testid="confirm-discard-draft"
>
Discard changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<DiscardDraftDialog
open={discardOpen}
onOpenChange={setDiscardOpen}
pending={discard.isPending}
testId="confirm-discard-draft"
onConfirm={() =>
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.
</DiscardDraftDialog>
{/*
* Not dismissable: until one version wins, every further save fails, so