|
@@ -28,7 +28,9 @@ import ReactFlow, {
|
|
|
import { BackgroundVariant } from '@reactflow/background'
|
|
import { BackgroundVariant } from '@reactflow/background'
|
|
|
import 'reactflow/dist/style.css'
|
|
import 'reactflow/dist/style.css'
|
|
|
import { useCallback, useEffect, useState, useRef, useMemo } from 'react'
|
|
import { useCallback, useEffect, useState, useRef, useMemo } from 'react'
|
|
|
|
|
+import { AlertTriangle } from 'lucide-react'
|
|
|
import { wsClient } from '../api/client'
|
|
import { wsClient } from '../api/client'
|
|
|
|
|
+import { useWorkflowPresence } from '../hooks/useWorkflowPresence'
|
|
|
import type { AvailableField } from '../components/ConditionBuilder'
|
|
import type { AvailableField } from '../components/ConditionBuilder'
|
|
|
import type { FieldInfo } from '../components/AvailableDataPanel'
|
|
import type { FieldInfo } from '../components/AvailableDataPanel'
|
|
|
import { ExecutionListPanel } from '../components/workflow/ExecutionListPanel'
|
|
import { ExecutionListPanel } from '../components/workflow/ExecutionListPanel'
|
|
@@ -559,6 +561,19 @@ function WorkflowEditorInner() {
|
|
|
enabled: !!id,
|
|
enabled: !!id,
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
|
|
+ // The version currently on the canvas. Kept in a ref because saving reads it
|
|
|
|
|
+ // and must not be re-created every time it changes.
|
|
|
|
|
+ const { others: otherViewers, savedByOther, dismissSavedByOther } = useWorkflowPresence(id)
|
|
|
|
|
+ // The save handler needs the latest value without being rebuilt for it.
|
|
|
|
|
+ const savedByOtherRef = useRef(savedByOther)
|
|
|
|
|
+ savedByOtherRef.current = savedByOther
|
|
|
|
|
+
|
|
|
|
|
+ const loadedVersionRef = useRef<number | null>(null)
|
|
|
|
|
+ const [saveConflict, setSaveConflict] = useState<{ username: string; currentVersion: number } | null>(null)
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (typeof workflow?.version === 'number') loadedVersionRef.current = workflow.version
|
|
|
|
|
+ }, [workflow])
|
|
|
|
|
+
|
|
|
const { data: nodeDefinitions } = useQuery({
|
|
const { data: nodeDefinitions } = useQuery({
|
|
|
queryKey: ['nodes'],
|
|
queryKey: ['nodes'],
|
|
|
queryFn: () => nodesApi.list(),
|
|
queryFn: () => nodesApi.list(),
|
|
@@ -722,12 +737,27 @@ function WorkflowEditorInner() {
|
|
|
|
|
|
|
|
const saveMutation = useMutation({
|
|
const saveMutation = useMutation({
|
|
|
mutationFn: (data: Partial<Workflow>) => workflowsApi.update(id!, data),
|
|
mutationFn: (data: Partial<Workflow>) => workflowsApi.update(id!, data),
|
|
|
- onSuccess: () => {
|
|
|
|
|
|
|
+ onSuccess: (saved: any) => {
|
|
|
queryClient.invalidateQueries({ queryKey: ['workflow', id] })
|
|
queryClient.invalidateQueries({ queryKey: ['workflow', id] })
|
|
|
setHasChanges(false)
|
|
setHasChanges(false)
|
|
|
|
|
+ setSaveConflict(null)
|
|
|
|
|
+ dismissSavedByOther()
|
|
|
|
|
+ if (typeof saved?.version === 'number') loadedVersionRef.current = saved.version
|
|
|
showToast('success', 'Workflow saved successfully')
|
|
showToast('success', 'Workflow saved successfully')
|
|
|
},
|
|
},
|
|
|
onError: (error: any) => {
|
|
onError: (error: any) => {
|
|
|
|
|
+ // A refused save is not a failure to report and forget: the work is still
|
|
|
|
|
+ // on the canvas, and the user has to be given the choice of what happens
|
|
|
|
|
+ // to it rather than being told "failed" and left to guess.
|
|
|
|
|
+ const conflict = error?.response?.status === 409 ? error.response.data : null
|
|
|
|
|
+ if (conflict) {
|
|
|
|
|
+ setSaveConflict({
|
|
|
|
|
+ username: conflict.current?.updatedByUsername || savedByOtherRef.current?.username || 'Someone else',
|
|
|
|
|
+ currentVersion: Number(conflict.currentVersion) || 0,
|
|
|
|
|
+ })
|
|
|
|
|
+ showToast('error', 'Someone else saved this workflow while you were editing it')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
showToast('error', `Failed to save: ${error.message}`)
|
|
showToast('error', `Failed to save: ${error.message}`)
|
|
|
},
|
|
},
|
|
|
})
|
|
})
|
|
@@ -1463,7 +1493,11 @@ function WorkflowEditorInner() {
|
|
|
nodes: workflowNodes,
|
|
nodes: workflowNodes,
|
|
|
connections: workflowConnections,
|
|
connections: workflowConnections,
|
|
|
settings: workflowSettings,
|
|
settings: workflowSettings,
|
|
|
- })
|
|
|
|
|
|
|
+ // The version this editor loaded. The server refuses the save if the
|
|
|
|
|
+ // stored workflow has moved on since, rather than overwriting whoever
|
|
|
|
|
+ // got there first.
|
|
|
|
|
+ expectedVersion: loadedVersionRef.current ?? undefined,
|
|
|
|
|
+ } as any)
|
|
|
}, [nodes, edges, saveMutation, workflowSettings, workflowName])
|
|
}, [nodes, edges, saveMutation, workflowSettings, workflowName])
|
|
|
|
|
|
|
|
// Handle workflow rename
|
|
// Handle workflow rename
|
|
@@ -2670,6 +2704,38 @@ function WorkflowEditorInner() {
|
|
|
isViewingExecution && pinnedExecution ? 'pt-12' : ''
|
|
isViewingExecution && pinnedExecution ? 'pt-12' : ''
|
|
|
}`}
|
|
}`}
|
|
|
>
|
|
>
|
|
|
|
|
+ {/* Someone else has saved this workflow, or refused this editor's save.
|
|
|
|
|
+ Both mean the same thing to the person sitting here: what is on the
|
|
|
|
|
+ canvas is no longer what is stored. */}
|
|
|
|
|
+ {(saveConflict || savedByOther) && !isViewingExecution && (
|
|
|
|
|
+ <div className="flex items-center gap-3 px-4 py-2 bg-amber-50 dark:bg-amber-900/30 border-b border-amber-300 dark:border-amber-700 text-sm text-amber-900 dark:text-amber-200">
|
|
|
|
|
+ <AlertTriangle className="w-4 h-4 shrink-0" />
|
|
|
|
|
+ <span className="flex-1">
|
|
|
|
|
+ {saveConflict
|
|
|
|
|
+ ? `${saveConflict.username} saved this workflow while you were editing it, so your save was refused. Your changes are still here - reload to take their version, or copy what you need out first.`
|
|
|
|
|
+ : `${savedByOther!.username} just saved this workflow. Your copy is out of date${hasChanges ? ', and you have unsaved changes' : ''}.`}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => {
|
|
|
|
|
+ // A reload throws away whatever is on the canvas, so it is a
|
|
|
|
|
+ // button somebody presses, never something that happens to them
|
|
|
|
|
+ // mid-edit.
|
|
|
|
|
+ queryClient.invalidateQueries({ queryKey: ['workflow', id] })
|
|
|
|
|
+ window.location.reload()
|
|
|
|
|
+ }}
|
|
|
|
|
+ className="px-2 py-1 rounded bg-amber-600 text-white hover:bg-amber-700 shrink-0"
|
|
|
|
|
+ >
|
|
|
|
|
+ Reload
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => { setSaveConflict(null); dismissSavedByOther() }}
|
|
|
|
|
+ className="px-2 py-1 rounded hover:bg-amber-100 dark:hover:bg-amber-800 shrink-0"
|
|
|
|
|
+ >
|
|
|
|
|
+ Dismiss
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
{/* Execution Viewer Banner */}
|
|
{/* Execution Viewer Banner */}
|
|
|
{isViewingExecution && pinnedExecution && (
|
|
{isViewingExecution && pinnedExecution && (
|
|
|
<ExecutionViewerBanner
|
|
<ExecutionViewerBanner
|
|
@@ -2706,6 +2772,7 @@ function WorkflowEditorInner() {
|
|
|
onExecuteTrigger={executeTrigger}
|
|
onExecuteTrigger={executeTrigger}
|
|
|
onAddNode={() => setShowNodePicker(true)}
|
|
onAddNode={() => setShowNodePicker(true)}
|
|
|
onAutoLayout={autoLayout}
|
|
onAutoLayout={autoLayout}
|
|
|
|
|
+ otherViewers={otherViewers}
|
|
|
onUndo={() => { if (history.undo()) setHasChanges(true) }}
|
|
onUndo={() => { if (history.undo()) setHasChanges(true) }}
|
|
|
onRedo={() => { if (history.redo()) setHasChanges(true) }}
|
|
onRedo={() => { if (history.redo()) setHasChanges(true) }}
|
|
|
canUndo={history.canUndo}
|
|
canUndo={history.canUndo}
|