|
|
@@ -32,11 +32,13 @@ import { AlertTriangle } from 'lucide-react'
|
|
|
import { wsClient } from '../api/client'
|
|
|
import { useWorkflowPresence } from '../hooks/useWorkflowPresence'
|
|
|
import { useWorkflowLocks } from '../hooks/useWorkflowLocks'
|
|
|
+import { useAutosave } from '../hooks/useAutosave'
|
|
|
import { diffWorkflows, graphDelta, mergeWorkflows, summariseChanges, type Change } from '../utils/workflowDiff'
|
|
|
import type { AvailableField } from '../components/ConditionBuilder'
|
|
|
import type { FieldInfo } from '../components/AvailableDataPanel'
|
|
|
import { ExecutionListPanel } from '../components/workflow/ExecutionListPanel'
|
|
|
import { ExecutionViewerBanner } from '../components/workflow/ExecutionViewerBanner'
|
|
|
+import { VersionHistoryPanel } from '../components/workflow/VersionHistoryPanel'
|
|
|
import { useExecutionPinStore } from '../stores/executionPinStore'
|
|
|
import type { ExecutionDetail, WorkflowNode as WorkflowNodeType, Connection as WorkflowConnection } from '../api/workflows'
|
|
|
|
|
|
@@ -587,6 +589,14 @@ function WorkflowEditorInner() {
|
|
|
// Whether those changes were taken onto this canvas automatically, or are
|
|
|
// still only a warning because taking them would have destroyed something.
|
|
|
const [autoSynced, setAutoSynced] = useState(false)
|
|
|
+ const [publishedVersion, setPublishedVersion] = useState<number | null>(null)
|
|
|
+ // Answered by the server by comparing the graphs, not the version numbers -
|
|
|
+ // publishing is itself a write, so a number comparison is always one behind.
|
|
|
+ const [hasUnpublishedChanges, setHasUnpublishedChanges] = useState(false)
|
|
|
+ const [showVersions, setShowVersions] = useState(false)
|
|
|
+ // Autosave is set up before handleSave is defined, and calling it through a
|
|
|
+ // ref avoids reordering several hundred lines to satisfy the compiler.
|
|
|
+ const handleSaveRef = useRef<() => void>(() => {})
|
|
|
|
|
|
const loadedVersionRef = useRef<number | null>(null)
|
|
|
const { others: otherViewers, myOtherTabs, savedByOther, dismissSavedByOther } =
|
|
|
@@ -610,6 +620,9 @@ function WorkflowEditorInner() {
|
|
|
useEffect(() => {
|
|
|
if (!workflow) return
|
|
|
if (typeof workflow.version === 'number') loadedVersionRef.current = workflow.version
|
|
|
+ const published = workflow.publishedVersion
|
|
|
+ setPublishedVersion(typeof published === 'number' && published > 0 ? published : null)
|
|
|
+ setHasUnpublishedChanges(workflow.hasUnpublishedChanges !== false)
|
|
|
if (baselineVersionRef.current === (workflow.version ?? null)) return
|
|
|
baselineVersionRef.current = workflow.version ?? null
|
|
|
baselineRef.current = {
|
|
|
@@ -880,6 +893,26 @@ function WorkflowEditorInner() {
|
|
|
},
|
|
|
})
|
|
|
|
|
|
+ const publishMutation = useMutation({
|
|
|
+ mutationFn: () => workflowsApi.publish(id!),
|
|
|
+ onSuccess: (result) => {
|
|
|
+ queryClient.invalidateQueries({ queryKey: ['workflow', id] })
|
|
|
+ queryClient.invalidateQueries({ queryKey: ['workflow-versions', id] })
|
|
|
+ setPublishedVersion(result.publishedVersion)
|
|
|
+ setHasUnpublishedChanges(false)
|
|
|
+ showToast('success', `Published version ${result.publishedVersion}. Triggers will run this from now on.`)
|
|
|
+ },
|
|
|
+ onError: (error: any) => {
|
|
|
+ showToast('error', error?.response?.data?.error || `Could not publish: ${error.message}`)
|
|
|
+ },
|
|
|
+ })
|
|
|
+
|
|
|
+ // Autosave, so nobody has to remember to press anything. Edits are grouped -
|
|
|
+ // see the hook for why one version per keystroke would be no history at all.
|
|
|
+ useAutosave(nodes, hasChanges, !isViewingExecution && !!id && !saveMutation.isPending, () => {
|
|
|
+ handleSaveRef.current()
|
|
|
+ })
|
|
|
+
|
|
|
// Duplicating copies what is STORED, so unsaved edits on the canvas would not
|
|
|
// make it into the copy. Rather than quietly producing a copy of an older
|
|
|
// version, refuse and say so - a silently stale duplicate is very hard to
|
|
|
@@ -1720,6 +1753,7 @@ function WorkflowEditorInner() {
|
|
|
expectedVersion: loadedVersionRef.current ?? undefined,
|
|
|
} as any)
|
|
|
}, [currentGraph, saveMutation])
|
|
|
+ handleSaveRef.current = handleSave
|
|
|
|
|
|
// Handle workflow rename
|
|
|
const handleRename = useCallback((newName: string) => {
|
|
|
@@ -3037,6 +3071,27 @@ function WorkflowEditorInner() {
|
|
|
</div>
|
|
|
)}
|
|
|
|
|
|
+ {showVersions && id && (
|
|
|
+ <VersionHistoryPanel
|
|
|
+ workflowId={id}
|
|
|
+ publishedVersion={publishedVersion}
|
|
|
+ onClose={() => setShowVersions(false)}
|
|
|
+ onRestore={(restored) => {
|
|
|
+ // Restoring puts the old graph on the canvas as unsaved work rather
|
|
|
+ // than writing it straight to the record. It is somebody's edit
|
|
|
+ // like any other - they can look at it, change their mind, or save
|
|
|
+ // it, and the version they came from is still there either way.
|
|
|
+ setNodes(restored.nodes.map((n: any) =>
|
|
|
+ makeNodeFromStored(n, nodeDefsMap, () => executeWorkflow(), executeTrigger, executionState)))
|
|
|
+ setEdges(buildEdgesFromStored(restored.connections))
|
|
|
+ if (restored.name) setWorkflowName(restored.name)
|
|
|
+ setHasChanges(true)
|
|
|
+ setShowVersions(false)
|
|
|
+ showToast('success', 'Put that version on the canvas. It saves as a new version - what runs does not change until you publish.')
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+
|
|
|
{/* Execution Viewer Banner */}
|
|
|
{isViewingExecution && pinnedExecution && (
|
|
|
<ExecutionViewerBanner
|
|
|
@@ -3073,6 +3128,11 @@ function WorkflowEditorInner() {
|
|
|
onExecuteTrigger={executeTrigger}
|
|
|
onAddNode={() => setShowNodePicker(true)}
|
|
|
onAutoLayout={autoLayout}
|
|
|
+ onPublish={() => publishMutation.mutate()}
|
|
|
+ isPublishing={publishMutation.isPending}
|
|
|
+ publishedVersion={publishedVersion}
|
|
|
+ hasUnpublishedChanges={hasUnpublishedChanges}
|
|
|
+ onShowVersions={() => setShowVersions(true)}
|
|
|
otherViewers={otherViewers}
|
|
|
myOtherTabs={myOtherTabs}
|
|
|
onUndo={() => { if (history.undo()) setHasChanges(true) }}
|