feat: allow users to name flow snapshots when saving (LE-874) (#12742)

Wire up the existing backend `description` field so users can optionally
name snapshots. The name appears in the version sidebar, deployment
modal version list, and canvas preview badge.

- Add SaveVersionDialog modal for entering version name on save
- Extract shared VersionLabel component for consistent display
- Thread previewDescription through versionPreviewStore to overlay badge
This commit is contained in:
Viktor Avelino
2026-04-17 10:35:08 -04:00
committed by GitHub
parent f748738c74
commit 351e2589c2
8 changed files with 221 additions and 56 deletions

View File

@ -0,0 +1,23 @@
interface VersionLabelProps {
versionTag: string;
description?: string | null;
className?: string;
}
export default function VersionLabel({
versionTag,
description,
className,
}: VersionLabelProps) {
return (
<span className={className}>
{versionTag}
{description && (
<span className="font-normal text-muted-foreground">
{" \u2014 "}
{description}
</span>
)}
</span>
);
}

View File

@ -4,6 +4,7 @@ import { useSidebar } from "@/components/ui/sidebar";
import { usePostCreateSnapshot } from "@/controllers/API/queries/flow-version";
import useAlertStore from "@/stores/alertStore";
import CanvasBanner, { CanvasBannerButton } from "./CanvasBanner";
import SaveVersionDialog from "./SaveVersionDialog";
interface SaveSnapshotButtonProps {
flowId: string;
@ -19,8 +20,12 @@ export default function SaveSnapshotButton({
usePostCreateSnapshot();
const [isSavingDisplay, setIsSavingDisplay] = useState(false);
const [savedSuccess, setSavedSuccess] = useState(false);
const [showModal, setShowModal] = useState(false);
const isBusy = isSavingDisplay || isCreating;
const handleDismiss = () => {
setShowModal(false);
// Switching the section unmounts the version sidebar, whose cleanup
// handles clearPreview, restoring auto-save, and restoring the
// inspection panel.
@ -28,18 +33,20 @@ export default function SaveSnapshotButton({
if (!open) toggleSidebar();
};
const handleSave = () => {
const handleSave = (description: string | null) => {
setShowModal(false);
setIsSavingDisplay(true);
createSnapshot(
{ flowId, description: null },
{ flowId, description },
{
onSuccess: () => {
setSuccessData({ title: "Version saved" });
setIsSavingDisplay(false);
setSavedSuccess(true);
},
onError: (err: any) => {
const detail = err?.response?.data?.detail;
onError: (err: unknown) => {
const detail = (err as { response?: { data?: { detail?: string } } })
?.response?.data?.detail;
setErrorData({
title: "Failed to save version",
...(detail ? { list: [detail] } : {}),
@ -50,39 +57,55 @@ export default function SaveSnapshotButton({
);
};
const renderSaveButtonContent = () => {
if (isBusy) {
return (
<>
<ForwardedIconComponent
name="Loader2"
className="h-3.5 w-3.5 animate-spin"
/>
Saving
</>
);
}
if (savedSuccess) {
return (
<>
<ForwardedIconComponent name="Check" className="h-3.5 w-3.5" />
Saved
</>
);
}
return "Save";
};
return (
<CanvasBanner
icon="BookMarked"
title="Save a version of your flow"
description="Capture the current state as a restore point"
actionSlot={
<div className="flex items-center gap-2">
<CanvasBannerButton variant="outline" onClick={handleDismiss}>
Keep Building
</CanvasBannerButton>
<CanvasBannerButton
onClick={handleSave}
disabled={isSavingDisplay || isCreating || savedSuccess}
>
{isSavingDisplay || isCreating ? (
<>
<ForwardedIconComponent
name="Loader2"
className="h-3.5 w-3.5 animate-spin"
/>
Saving
</>
) : savedSuccess ? (
<>
<ForwardedIconComponent name="Check" className="h-3.5 w-3.5" />
Saved
</>
) : (
"Save"
)}
</CanvasBannerButton>
</div>
}
/>
<>
<CanvasBanner
icon="BookMarked"
title="Save a version of your flow"
description="Capture the current state as a restore point"
actionSlot={
<div className="flex items-center gap-2">
<CanvasBannerButton variant="outline" onClick={handleDismiss}>
Keep Building
</CanvasBannerButton>
<CanvasBannerButton
onClick={() => setShowModal(true)}
disabled={isBusy || savedSuccess}
>
{renderSaveButtonContent()}
</CanvasBannerButton>
</div>
}
/>
<SaveVersionDialog
open={showModal}
onOpenChange={setShowModal}
onSave={handleSave}
/>
</>
);
}

View File

@ -0,0 +1,88 @@
import { useRef, useState } from "react";
import ForwardedIconComponent from "@/components/common/genericIconComponent";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
interface SaveVersionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (description: string | null) => void;
}
export default function SaveVersionDialog({
open,
onOpenChange,
onSave,
}: SaveVersionDialogProps) {
const [description, setDescription] = useState("");
const isComposing = useRef(false);
const handleOpenChange = (nextOpen: boolean) => {
onOpenChange(nextOpen);
if (!nextOpen) setDescription("");
};
const handleSave = () => {
onSave(description.trim() || null);
setDescription("");
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<div className="flex items-center gap-2">
<ForwardedIconComponent
name="BookMarked"
className="h-5 w-5 text-primary"
/>
Save Version
</div>
</DialogTitle>
<DialogDescription>
Give this version an optional name to help identify it later.
</DialogDescription>
</DialogHeader>
<Input
autoFocus
placeholder="Version name (optional)"
value={description}
onChange={(e) => setDescription(e.target.value)}
onCompositionStart={() => {
isComposing.current = true;
}}
onCompositionEnd={() => {
isComposing.current = false;
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !isComposing.current) {
handleSave();
}
}}
maxLength={500}
/>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => handleOpenChange(false)}
>
Cancel
</Button>
<Button size="sm" onClick={handleSave}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -8,6 +8,9 @@ import SaveSnapshotButton from "./SaveSnapshotButton";
export default function VersionPreviewOverlay() {
const previewLabel = useVersionPreviewStore((s) => s.previewLabel);
const previewId = useVersionPreviewStore((s) => s.previewId);
const previewDescription = useVersionPreviewStore(
(s) => s.previewDescription,
);
const isPreviewLoading = useVersionPreviewStore((s) => s.isPreviewLoading);
const currentFlowId = useFlowsManagerStore((state) => state.currentFlowId);
@ -15,14 +18,21 @@ export default function VersionPreviewOverlay() {
return (
<div className="version-preview-overlay pointer-events-none absolute inset-0 z-50">
<CanvasBadge>
<span className="h-2 w-2 shrink-0 rounded-lg bg-[#6366F1]" />
<span className="text-sm">
{previewLabel === "Current Draft"
? "Current Flow"
: `Previewing ${previewLabel}`}
</span>
<span className="text-muted-foreground text-sm">(Read-Only)</span>
<CanvasBadge className="flex-col items-start whitespace-normal">
<div className="flex items-center gap-2">
<span className="h-2 w-2 shrink-0 rounded-lg bg-[#6366F1]" />
<span className="text-sm">
{previewLabel === "Current Draft"
? "Current Flow"
: `Previewing ${previewLabel}`}
</span>
<span className="text-muted-foreground text-sm">(Read-Only)</span>
</div>
{previewDescription && (
<span className="max-w-[300px] pl-4 text-xs text-muted-foreground">
{previewDescription}
</span>
)}
</CanvasBadge>
{isPreviewLoading && (

View File

@ -1,4 +1,5 @@
import ForwardedIconComponent from "@/components/common/genericIconComponent";
import VersionLabel from "@/components/common/versionLabelComponent";
import {
DropdownMenu,
DropdownMenuContent,
@ -42,8 +43,12 @@ export default function VersionListItem({
isSelected && "border-l-2 border-l-[#6366F1] !bg-[#6366F1]/10",
)}
>
<div className="flex flex-col items-start">
<span className="font-medium text-sm pb-1">{entry.version_tag}</span>
<div className="flex min-w-0 flex-col items-start">
<VersionLabel
versionTag={entry.version_tag}
description={entry.description}
className="w-full truncate font-medium text-sm pb-1"
/>
<span className="text-xs text-muted-foreground">
{formatTimestamp(entry.created_at)}
</span>

View File

@ -160,6 +160,7 @@ export function useFlowVersionSidebar(flowId: string) {
processedPreview.edges,
tag,
selectedId,
selectedEntryFull?.description ?? null,
);
} else if (selectedId === CURRENT_DRAFT_ID || processedPreview?.error) {
setPreview(
@ -173,6 +174,7 @@ export function useFlowVersionSidebar(flowId: string) {
processedPreview,
selectedId,
selectedEntryFull?.version_tag,
selectedEntryFull?.description,
setPreview,
]);

View File

@ -1,4 +1,5 @@
import { memo } from "react";
import VersionLabel from "@/components/common/versionLabelComponent";
import { Badge } from "@/components/ui/badge";
import type { FlowType } from "@/types/flow";
import type { FlowVersionEntry } from "@/types/flow/version";
@ -45,15 +46,19 @@ export const VersionPanel = memo(function VersionPanel({
<div className="flex flex-1 flex-col overflow-hidden px-4 py-2">
<h3 className="py-2 text-lg font-semibold">{selectedFlow.name}</h3>
<div className="flex-1 space-y-3 overflow-y-auto py-3">
{isLoadingVersions ? (
{isLoadingVersions && (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
Loading versions...
</div>
) : versions.length === 0 ? (
)}
{!isLoadingVersions && versions.length === 0 && (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
No versions found
</div>
) : (
)}
{!isLoadingVersions &&
versions.map((version) => {
const isAttachedVersion = attachedEntry?.versionId === version.id;
return (
@ -69,14 +74,18 @@ export const VersionPanel = memo(function VersionPanel({
: "border-transparent bg-muted hover:border-border",
)}
>
<span className="flex flex-col gap-1">
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="flex items-center gap-2 text-sm font-medium leading-tight">
{version.version_tag}
<VersionLabel
versionTag={version.version_tag}
description={version.description}
className="truncate"
/>
{isAttachedVersion && (
<Badge
variant="secondaryStatic"
size="tag"
className="bg-accent-blue-muted text-accent-blue-muted-foreground"
className="shrink-0 bg-accent-blue-muted text-accent-blue-muted-foreground"
>
ATTACHED
</Badge>
@ -88,8 +97,7 @@ export const VersionPanel = memo(function VersionPanel({
</span>
</button>
);
})
)}
})}
</div>
</div>
</>

View File

@ -7,6 +7,8 @@ interface VersionPreviewState {
previewLabel: string | null;
/** The version entry ID currently being previewed, or null for current draft. */
previewId: string | null;
/** Optional description of the previewed version. */
previewDescription: string | null;
/** True while the sidebar is fetching a version entry to display. */
isPreviewLoading: boolean;
/** True after a version was activated via the restore hook. The sidebar
@ -18,6 +20,7 @@ interface VersionPreviewState {
edges: EdgeType[],
label: string,
id?: string | null,
description?: string | null,
) => void;
clearPreview: () => void;
}
@ -27,15 +30,17 @@ const useVersionPreviewStore = create<VersionPreviewState>((set) => ({
previewEdges: null,
previewLabel: null,
previewId: null,
previewDescription: null,
isPreviewLoading: false,
didRestore: false,
setPreviewLoading: (loading) => set({ isPreviewLoading: loading }),
setPreview: (nodes, edges, label, id = null) =>
setPreview: (nodes, edges, label, id = null, description = null) =>
set({
previewNodes: nodes,
previewEdges: edges,
previewLabel: label,
previewId: id,
previewDescription: description,
}),
clearPreview: () =>
set({
@ -43,6 +48,7 @@ const useVersionPreviewStore = create<VersionPreviewState>((set) => ({
previewEdges: null,
previewLabel: null,
previewId: null,
previewDescription: null,
isPreviewLoading: false,
// NOTE: didRestore is intentionally NOT reset here. It is read and
// reset by the sidebar unmount cleanup, which runs after clearPreview.