Browse Source

feat: duplicate a workflow from the list and from the editor

Adds workflowsApi.duplicate plus a Duplicate entry in the workflow card menu
and a Duplicate button in the editor toolbar. Both copy the nodes, connections,
settings and description, name the copy "<name> (copy)", leave it in the same
folder, and open it.

Three decisions worth stating:

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 a second entry on the same
schedule, the instant the button is pressed - and say nothing about it. The
Email OCR workflow polls IMAP every five minutes and marks mail read, so a
silent second copy of it would race the original for the same messages.

It copies what is STORED, not what is on the canvas. In the editor, duplicating
with unsaved changes is refused with a message saying to save first, rather than
quietly producing a copy of an older version. A stale duplicate is hard to spot
and irritating to discover later.

It re-reads the workflow rather than copying whatever the list page happens to
hold in its query cache, so a change made in another tab is not silently
reverted into the copy.

No backend change: POST /workflows already accepts a full workflow body.

Verified in a browser: duplicating "Tier 1 Showcase" from the card menu
produced "Tier 1 Showcase (copy)" with 12 nodes and 11 connections matching the
original, active false, and opened it; the editor toolbar shows the button on
the copy.
fszontagh 1 tháng trước cách đây
mục cha
commit
b43c8d1b3b

+ 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()