Просмотр исходного кода

feat: let a folder be moved to another project

The backend has always accepted a projectId on a folder update and, when
it differs, patched every workflow directly in that folder to the new
project too, so a folder and its contents cannot end up split. The UI
never offered this - only move-to-parent, rename, create, and delete
existed for folders.

Add a "Move to Project" action on each folder card, matching the wording
and interaction of the existing workflow move-to-project picker. Before
the user commits, the confirmation shows how many workflows directly in
the folder will move with it, and - since the backend only patches
workflows with groupId equal to this folder, not the contents of
subfolders - a separate warning when the folder has subfolders, so the
UI does not imply they and their workflows move too. A refused move
(target project the caller cannot write to) surfaces the backend's own
error message rather than a generic failure.

Verified against the running server: created a folder with two
workflows and a subfolder-with-workflow, confirmed the pre-move counts
were accurate, moved the folder, and confirmed via the API that the
folder and its two direct workflows changed project while the subfolder
and its workflow stayed behind in the old project - exactly as the
confirmation dialog said they would. Also confirmed, via a second
account restricted to its own project, that attempting a cross-project
move that the backend refuses returns the same message this modal
renders: "You cannot move this folder into a project you cannot write
to".
fszontagh 1 месяц назад
Родитель
Сommit
3aaad1a815
3 измененных файлов с 121 добавлено и 2 удалено
  1. 11 0
      webui/src/api/workflowGroups.ts
  2. 14 2
      webui/src/components/GroupCard.tsx
  3. 96 0
      webui/src/pages/WorkflowsPage.tsx

+ 11 - 0
webui/src/api/workflowGroups.ts

@@ -105,4 +105,15 @@ export const workflowGroupsApi = {
     })
     })
     return transformGroup(response.data)
     return transformGroup(response.data)
   },
   },
+
+  /**
+   * Move a folder to a different project. The backend patches every workflow
+   * directly in this folder to the new project too, so the folder and its
+   * contents cannot end up split across projects. It does not reach into
+   * subfolders - see the folder move confirmation in WorkflowsPage.
+   */
+  moveToProject: async (id: string, projectId: string) => {
+    const response = await api.put(`/workflow-groups/${id}`, { projectId })
+    return transformGroup(response.data)
+  },
 }
 }

+ 14 - 2
webui/src/components/GroupCard.tsx

@@ -1,5 +1,5 @@
 import { useState } from 'react'
 import { useState } from 'react'
-import { Folder, MoreVertical, Pencil, Trash2, FolderInput } from 'lucide-react'
+import { Folder, MoreVertical, Pencil, Trash2, FolderInput, FolderKanban } from 'lucide-react'
 import { WorkflowGroup } from '../api/workflowGroups'
 import { WorkflowGroup } from '../api/workflowGroups'
 import { formatDistanceToNow } from 'date-fns'
 import { formatDistanceToNow } from 'date-fns'
 
 
@@ -8,6 +8,7 @@ interface GroupCardProps {
   onClick: () => void
   onClick: () => void
   onRename: () => void
   onRename: () => void
   onMove: () => void
   onMove: () => void
+  onMoveToProject: () => void
   onDelete: () => void
   onDelete: () => void
 }
 }
 
 
@@ -28,7 +29,7 @@ function safeFormatDistanceToNow(timestamp: number | null | undefined): string {
   }
   }
 }
 }
 
 
-export function GroupCard({ group, onClick, onRename, onMove, onDelete }: GroupCardProps) {
+export function GroupCard({ group, onClick, onRename, onMove, onMoveToProject, onDelete }: GroupCardProps) {
   const [showMenu, setShowMenu] = useState(false)
   const [showMenu, setShowMenu] = useState(false)
 
 
   return (
   return (
@@ -91,6 +92,17 @@ export function GroupCard({ group, onClick, onRename, onMove, onDelete }: GroupC
                 >
                 >
                   <FolderInput className="w-4 h-4" /> Move
                   <FolderInput className="w-4 h-4" /> Move
                 </button>
                 </button>
+                <button
+                  onClick={(e) => {
+                    e.stopPropagation()
+                    onMoveToProject()
+                    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="A folder tidies a project up; a project decides who can see this at all"
+                >
+                  <FolderKanban className="w-4 h-4" /> Move to Project
+                </button>
                 <button
                 <button
                   onClick={(e) => {
                   onClick={(e) => {
                     e.stopPropagation()
                     e.stopPropagation()

+ 96 - 0
webui/src/pages/WorkflowsPage.tsx

@@ -87,6 +87,7 @@ export default function WorkflowsPage() {
   const [groupToRename, setGroupToRename] = useState<WorkflowGroup | null>(null)
   const [groupToRename, setGroupToRename] = useState<WorkflowGroup | null>(null)
   const [groupToDelete, setGroupToDelete] = useState<WorkflowGroup | null>(null)
   const [groupToDelete, setGroupToDelete] = useState<WorkflowGroup | null>(null)
   const [groupToMove, setGroupToMove] = useState<WorkflowGroup | null>(null)
   const [groupToMove, setGroupToMove] = useState<WorkflowGroup | null>(null)
+  const [groupToMoveProject, setGroupToMoveProject] = useState<WorkflowGroup | null>(null)
   const [workflowToMove, setWorkflowToMove] = useState<Workflow | null>(null)
   const [workflowToMove, setWorkflowToMove] = useState<Workflow | null>(null)
 
 
   // Fetch current path (breadcrumbs)
   // Fetch current path (breadcrumbs)
@@ -194,6 +195,42 @@ export default function WorkflowsPage() {
     },
     },
   })
   })
 
 
+  // What moving groupToMoveProject to another project actually does, worked out
+  // before the user commits to it: the backend patches every workflow directly
+  // in this folder to the new project too (workflow_group_controller.cpp), but
+  // it does not reach into subfolders - a subfolder and its own workflows stay
+  // behind in the old project. Both counts are shown in the confirmation so
+  // neither is a surprise afterwards.
+  const { data: moveProjectImpact } = useQuery({
+    queryKey: ['workflow-group-move-impact', groupToMoveProject?.id],
+    queryFn: async () => {
+      // pageSize 500 and counting the returned array, not the "total" field:
+      // the workflows endpoint's total reflects the page it just returned
+      // rather than the true count once pageSize is small, so a pageSize of 1
+      // would (and did, in testing) report "1 workflow" for a folder holding 2.
+      const [workflowsInFolder, subfolders] = await Promise.all([
+        workflowsApi.list(1, 500, groupToMoveProject!.id),
+        workflowGroupsApi.list(groupToMoveProject!.id),
+      ])
+      return {
+        workflowCount: workflowsInFolder.workflows.length,
+        subfolderCount: subfolders.groups.length,
+      }
+    },
+    enabled: !!groupToMoveProject,
+  })
+
+  const moveGroupToProjectMutation = useMutation({
+    mutationFn: ({ id, projectId }: { id: string; projectId: string }) =>
+      workflowGroupsApi.moveToProject(id, projectId),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['workflow-groups'] })
+      queryClient.invalidateQueries({ queryKey: ['workflows'] })
+      queryClient.invalidateQueries({ queryKey: ['projects'] })
+      setGroupToMoveProject(null)
+    },
+  })
+
   // Workflow mutations
   // Workflow mutations
   const createWorkflowMutation = useMutation({
   const createWorkflowMutation = useMutation({
     mutationFn: workflowsApi.create,
     mutationFn: workflowsApi.create,
@@ -345,6 +382,64 @@ export default function WorkflowsPage() {
         </div>
         </div>
       )}
       )}
 
 
+      {groupToMoveProject && (
+        <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={() => setGroupToMoveProject(null)}>
+          <div className="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-sm p-5" onClick={(e) => e.stopPropagation()}>
+            <h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-1">
+              Move "{groupToMoveProject.name}"
+            </h3>
+            <p className="text-xs text-gray-500 dark:text-gray-400 mb-2">
+              A project decides who can see and change this folder. Moving it may
+              take it away from people who can reach it now, or hand it to people who cannot.
+            </p>
+            <p className="text-xs text-gray-500 dark:text-gray-400 mb-2">
+              {moveProjectImpact === undefined
+                ? 'Checking what is in this folder...'
+                : moveProjectImpact.workflowCount === 0
+                ? 'This folder has no workflows directly in it.'
+                : `This will also move ${moveProjectImpact.workflowCount} workflow${moveProjectImpact.workflowCount === 1 ? '' : 's'} directly in this folder to the new project.`}
+            </p>
+            {moveProjectImpact !== undefined && moveProjectImpact.subfolderCount > 0 && (
+              <p className="text-xs text-amber-600 dark:text-amber-400 mb-3 flex items-start gap-1.5">
+                <AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-px" />
+                <span>
+                  This folder has {moveProjectImpact.subfolderCount} subfolder
+                  {moveProjectImpact.subfolderCount === 1 ? '' : 's'}. Only this folder and the
+                  workflows directly in it move - subfolders and their workflows stay in the
+                  current project.
+                </span>
+              </p>
+            )}
+            <div className="space-y-1 max-h-60 overflow-y-auto">
+              {projects.map((p) => (
+                <button
+                  key={p._id}
+                  onClick={() => moveGroupToProjectMutation.mutate({ id: groupToMoveProject.id, projectId: p._id })}
+                  disabled={p._id === groupToMoveProject.projectId || moveGroupToProjectMutation.isPending}
+                  className="w-full px-3 py-2 text-left text-sm rounded hover:bg-gray-50 dark:hover:bg-slate-700 disabled:opacity-40 flex items-center justify-between"
+                >
+                  <span className="text-gray-900 dark:text-gray-100">{p.name}</span>
+                  {p._id === groupToMoveProject.projectId && (
+                    <span className="text-xs text-gray-400">where it is now</span>
+                  )}
+                </button>
+              ))}
+            </div>
+            {moveGroupToProjectMutation.isError && (
+              <p className="text-xs text-red-600 dark:text-red-400 mt-3">
+                {(moveGroupToProjectMutation.error as any)?.response?.data?.error ||
+                  'Could not move this folder.'}
+              </p>
+            )}
+            <div className="flex justify-end mt-4">
+              <button onClick={() => setGroupToMoveProject(null)} className="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400">
+                Cancel
+              </button>
+            </div>
+          </div>
+        </div>
+      )}
+
       {isLoading ? (
       {isLoading ? (
         <div className="flex items-center justify-center py-12">
         <div className="flex items-center justify-center py-12">
           <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
           <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
@@ -386,6 +481,7 @@ export default function WorkflowsPage() {
                     onClick={() => navigateToGroup(group.id)}
                     onClick={() => navigateToGroup(group.id)}
                     onRename={() => setGroupToRename(group)}
                     onRename={() => setGroupToRename(group)}
                     onMove={() => setGroupToMove(group)}
                     onMove={() => setGroupToMove(group)}
+                    onMoveToProject={() => setGroupToMoveProject(group)}
                     onDelete={() => setGroupToDelete(group)}
                     onDelete={() => setGroupToDelete(group)}
                   />
                   />
                 ))}
                 ))}