diff --git a/src/frontend/src/components/common/versionLabelComponent/index.tsx b/src/frontend/src/components/common/versionLabelComponent/index.tsx new file mode 100644 index 0000000000..e9117d0b3e --- /dev/null +++ b/src/frontend/src/components/common/versionLabelComponent/index.tsx @@ -0,0 +1,23 @@ +interface VersionLabelProps { + versionTag: string; + description?: string | null; + className?: string; +} + +export default function VersionLabel({ + versionTag, + description, + className, +}: VersionLabelProps) { + return ( + + {versionTag} + {description && ( + + {" \u2014 "} + {description} + + )} + + ); +} diff --git a/src/frontend/src/pages/FlowPage/components/PageComponent/components/SaveSnapshotButton.tsx b/src/frontend/src/pages/FlowPage/components/PageComponent/components/SaveSnapshotButton.tsx index 0f8649506d..b24744ddab 100644 --- a/src/frontend/src/pages/FlowPage/components/PageComponent/components/SaveSnapshotButton.tsx +++ b/src/frontend/src/pages/FlowPage/components/PageComponent/components/SaveSnapshotButton.tsx @@ -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 ( + <> + + Saving… + + ); + } + if (savedSuccess) { + return ( + <> + + Saved + + ); + } + return "Save"; + }; + return ( - - - Keep Building - - - {isSavingDisplay || isCreating ? ( - <> - - Saving… - - ) : savedSuccess ? ( - <> - - Saved - - ) : ( - "Save" - )} - - - } - /> + <> + + + Keep Building + + setShowModal(true)} + disabled={isBusy || savedSuccess} + > + {renderSaveButtonContent()} + + + } + /> + + + ); } diff --git a/src/frontend/src/pages/FlowPage/components/PageComponent/components/SaveVersionDialog.tsx b/src/frontend/src/pages/FlowPage/components/PageComponent/components/SaveVersionDialog.tsx new file mode 100644 index 0000000000..67c920c2ec --- /dev/null +++ b/src/frontend/src/pages/FlowPage/components/PageComponent/components/SaveVersionDialog.tsx @@ -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 ( + + + + +
+ + Save Version +
+
+ + Give this version an optional name to help identify it later. + +
+ setDescription(e.target.value)} + onCompositionStart={() => { + isComposing.current = true; + }} + onCompositionEnd={() => { + isComposing.current = false; + }} + onKeyDown={(e) => { + if (e.key === "Enter" && !isComposing.current) { + handleSave(); + } + }} + maxLength={500} + /> + + + + +
+
+ ); +} diff --git a/src/frontend/src/pages/FlowPage/components/PageComponent/components/VersionPreviewOverlay.tsx b/src/frontend/src/pages/FlowPage/components/PageComponent/components/VersionPreviewOverlay.tsx index ad8d0a3db2..ec158bc80a 100644 --- a/src/frontend/src/pages/FlowPage/components/PageComponent/components/VersionPreviewOverlay.tsx +++ b/src/frontend/src/pages/FlowPage/components/PageComponent/components/VersionPreviewOverlay.tsx @@ -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 (
- - - - {previewLabel === "Current Draft" - ? "Current Flow" - : `Previewing ${previewLabel}`} - - (Read-Only) + +
+ + + {previewLabel === "Current Draft" + ? "Current Flow" + : `Previewing ${previewLabel}`} + + (Read-Only) +
+ {previewDescription && ( + + {previewDescription} + + )}
{isPreviewLoading && ( diff --git a/src/frontend/src/pages/FlowPage/components/flowSidebarComponent/components/FlowVersionSidebar/components/VersionListItem.tsx b/src/frontend/src/pages/FlowPage/components/flowSidebarComponent/components/FlowVersionSidebar/components/VersionListItem.tsx index 2a56afd0fa..780a621f7d 100644 --- a/src/frontend/src/pages/FlowPage/components/flowSidebarComponent/components/FlowVersionSidebar/components/VersionListItem.tsx +++ b/src/frontend/src/pages/FlowPage/components/flowSidebarComponent/components/FlowVersionSidebar/components/VersionListItem.tsx @@ -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", )} > -
- {entry.version_tag} +
+ {formatTimestamp(entry.created_at)} diff --git a/src/frontend/src/pages/FlowPage/components/flowSidebarComponent/components/FlowVersionSidebar/use-flow-version-sidebar.ts b/src/frontend/src/pages/FlowPage/components/flowSidebarComponent/components/FlowVersionSidebar/use-flow-version-sidebar.ts index 25fe21386b..8347b2e7b7 100644 --- a/src/frontend/src/pages/FlowPage/components/flowSidebarComponent/components/FlowVersionSidebar/use-flow-version-sidebar.ts +++ b/src/frontend/src/pages/FlowPage/components/flowSidebarComponent/components/FlowVersionSidebar/use-flow-version-sidebar.ts @@ -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, ]); diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-attach-flows-version-panel.tsx b/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-attach-flows-version-panel.tsx index 77d7a95236..1cefe1bcbf 100644 --- a/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-attach-flows-version-panel.tsx +++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-attach-flows-version-panel.tsx @@ -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({

{selectedFlow.name}

- {isLoadingVersions ? ( + {isLoadingVersions && (
Loading versions...
- ) : versions.length === 0 ? ( + )} + + {!isLoadingVersions && versions.length === 0 && (
No versions found
- ) : ( + )} + + {!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", )} > - + - {version.version_tag} + {isAttachedVersion && ( ATTACHED @@ -88,8 +97,7 @@ export const VersionPanel = memo(function VersionPanel({ ); - }) - )} + })}
diff --git a/src/frontend/src/stores/versionPreviewStore.ts b/src/frontend/src/stores/versionPreviewStore.ts index 71786d2ea4..05cacc08e9 100644 --- a/src/frontend/src/stores/versionPreviewStore.ts +++ b/src/frontend/src/stores/versionPreviewStore.ts @@ -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((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((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.