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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user