mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-25 20:20:08 +08:00
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:
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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 && (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -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.
|
||||
|
||||
Reference in New Issue
Block a user