Parcourir la source

Merge branch 'duplicate-workflow': duplicate a workflow from the list and the editor

fszontagh il y a 1 mois
Parent
commit
afa7cc3c3c

+ 28 - 0
webui/src/api/workflows.ts

@@ -123,6 +123,34 @@ export const workflowsApi = {
     return transformWorkflow(response.data)
   },
 
+  /**
+   * Copy a workflow, nodes and connections and all.
+   *
+   * The copy is always created INACTIVE, whatever the original was. Activating
+   * is a deliberate act: duplicating an active workflow that carries a trigger
+   * would otherwise put a second poller on the same mailbox or schedule the
+   * moment the button is pressed, and do it silently.
+   *
+   * The copy lands in the same folder as the original unless told otherwise.
+   */
+  duplicate: async (id: string, options?: { name?: string; groupId?: string | null }) => {
+    // Read the workflow fresh rather than copying whatever a list page happens
+    // to be holding, so an edit made in another tab is not silently reverted
+    // into the copy.
+    const source = await workflowsApi.get(id)
+
+    const response = await api.post('/workflows', {
+      name: options?.name ?? `${source.name} (copy)`,
+      description: source.description,
+      groupId: options?.groupId !== undefined ? options.groupId : source.groupId,
+      nodes: source.nodes || [],
+      connections: source.connections || [],
+      settings: source.settings || {},
+      active: false,
+    })
+    return transformWorkflow(response.data)
+  },
+
   delete: async (id: string) => {
     await api.delete(`/workflows/${id}`)
   },

+ 14 - 1
webui/src/components/workflow/EditorHeader.tsx

@@ -1,4 +1,4 @@
-import { ArrowLeft, Save, Play, Pause, Plus, LayoutGrid, History, Pin, X, Cog, Loader2, ChevronDown, Pencil } from 'lucide-react'
+import { ArrowLeft, Save, Play, Pause, Plus, LayoutGrid, History, Pin, X, Cog, Loader2, ChevronDown, Pencil, Copy } from 'lucide-react'
 import { useState, useRef, useEffect, useCallback } from 'react'
 import type { ExecutionDetail } from '../../api/workflows'
 
@@ -31,6 +31,8 @@ interface EditorHeaderProps {
   onShowExecutionList: () => void
   onUnpinExecution: () => void
   onShowSettings: () => void
+  onDuplicate: () => void
+  isDuplicating: boolean
   onToggleExecutionPanel: () => void
   onRename?: (newName: string) => void
 }
@@ -58,6 +60,8 @@ export function EditorHeader({
   onShowExecutionList,
   onUnpinExecution,
   onShowSettings,
+  onDuplicate,
+  isDuplicating,
   onToggleExecutionPanel,
   onRename,
 }: EditorHeaderProps) {
@@ -209,6 +213,15 @@ export function EditorHeader({
             Clear
           </button>
         )}
+        <button
+          onClick={onDuplicate}
+          disabled={isDuplicating}
+          className="flex items-center gap-2 px-3 py-1.5 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg disabled:opacity-50"
+          title="Create an inactive copy of this workflow and open it"
+        >
+          {isDuplicating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Copy className="w-4 h-4" />}
+          Duplicate
+        </button>
         <button
           onClick={onShowSettings}
           className="flex items-center gap-2 px-3 py-1.5 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg"

+ 24 - 0
webui/src/pages/WorkflowEditorPage.tsx

@@ -445,6 +445,22 @@ function WorkflowEditorInner() {
     },
   })
 
+  // 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
+  // notice and very annoying to discover later.
+  const duplicateMutation = useMutation({
+    mutationFn: () => workflowsApi.duplicate(id!),
+    onSuccess: (copy) => {
+      queryClient.invalidateQueries({ queryKey: ['workflows'] })
+      showToast('success', `Duplicated as "${copy.name}". The copy is inactive.`)
+      navigate(`/workflows/${copy.id}`)
+    },
+    onError: (error: any) => {
+      showToast('error', `Could not duplicate: ${error.message}`)
+    },
+  })
+
   // Activation is a lifecycle action, not part of saving: it registers the
   // workflow with the scheduler. Uses the same endpoints as the workflows list.
   const toggleActiveMutation = useMutation({
@@ -2109,6 +2125,14 @@ function WorkflowEditorInner() {
         onAutoLayout={autoLayout}
         onShowExecutionList={() => setShowExecutionList(true)}
         onUnpinExecution={unpinExecution}
+        onDuplicate={() => {
+          if (hasChanges) {
+            showToast('error', 'Save your changes first - a duplicate copies the saved version')
+            return
+          }
+          duplicateMutation.mutate()
+        }}
+        isDuplicating={duplicateMutation.isPending}
         onShowSettings={() => setShowWorkflowSettings(true)}
         onToggleExecutionPanel={() => setShowExecutionPanel(!showExecutionPanel)}
         onRename={handleRename}

+ 28 - 1
webui/src/pages/WorkflowsPage.tsx

@@ -3,7 +3,7 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom'
 import { workflowsApi, nodesApi, Workflow } from '../api/workflows'
 import { usersApi } from '../api/users'
 import { workflowGroupsApi, WorkflowGroup } from '../api/workflowGroups'
-import { Plus, Play, Pause, Trash2, MoreVertical, AlertTriangle, X, FolderPlus, FolderInput, Clock, User, Zap } from 'lucide-react'
+import { Plus, Play, Pause, Trash2, MoreVertical, AlertTriangle, X, FolderPlus, FolderInput, Clock, User, Zap, Copy } from 'lucide-react'
 import { useState } from 'react'
 import { formatDistanceToNow, format } from 'date-fns'
 import { GroupBreadcrumb } from '../components/GroupBreadcrumb'
@@ -216,6 +216,16 @@ export default function WorkflowsPage() {
     },
   })
 
+  const duplicateWorkflowMutation = useMutation({
+    mutationFn: (id: string) => workflowsApi.duplicate(id),
+    onSuccess: (copy) => {
+      queryClient.invalidateQueries({ queryKey: ['workflows'] })
+      // Straight into the copy: duplicating is almost always the first step of
+      // editing it, and the copy is inactive so nothing runs meanwhile.
+      navigate(`/workflows/${copy.id}`)
+    },
+  })
+
   const moveWorkflowMutation = useMutation({
     mutationFn: ({ id, groupId }: { id: string; groupId: string | null }) =>
       workflowsApi.moveToGroup(id, groupId),
@@ -346,6 +356,11 @@ export default function WorkflowsPage() {
                         toggleActiveMutation.mutate({ id: workflow.id, active: workflow.active })
                       }
                     }}
+                    onDuplicate={() => {
+                      if (workflow.id) {
+                        duplicateWorkflowMutation.mutate(workflow.id)
+                      }
+                    }}
                     onMove={() => setWorkflowToMove(workflow)}
                     onDelete={() => setWorkflowToDelete(workflow)}
                   />
@@ -472,6 +487,7 @@ function WorkflowCard({
   ownerName,
   triggers,
   onToggleActive,
+  onDuplicate,
   onMove,
   onDelete,
 }: {
@@ -480,6 +496,7 @@ function WorkflowCard({
   ownerName?: string
   triggers: TriggerSummary[]
   onToggleActive: () => void
+  onDuplicate: () => void
   onMove: () => void
   onDelete: () => void
 }) {
@@ -534,6 +551,16 @@ function WorkflowCard({
                     </>
                   )}
                 </button>
+                <button
+                  onClick={() => {
+                    onDuplicate()
+                    setShowMenu(false)
+                  }}
+                  className="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-700 flex items-center gap-2"
+                  title="Create an inactive copy of this workflow"
+                >
+                  <Copy className="w-4 h-4" /> Duplicate
+                </button>
                 <button
                   onClick={() => {
                     onMove()