From 9fae06f23e3200a809885b89dd3e2719f6de68a6 Mon Sep 17 00:00:00 2001 From: Kitty Li Date: Sun, 1 Feb 2026 19:51:23 -0800 Subject: [PATCH] feat(apollo-react): react flow task nodes --- .../StageNode/CrossStageTaskDrag.stories.tsx | 565 ++++ .../StageNode/DraggableTask.test.tsx | 371 --- .../components/StageNode/DraggableTask.tsx | 233 -- .../StageNode/DraggableTask.types.ts | 21 - .../components/StageNode/DropPlaceholder.tsx | 124 + .../StageNode/StageNode.stories.tsx | 2776 ++++++++++------- .../components/StageNode/StageNode.styles.ts | 2 +- .../canvas/components/StageNode/StageNode.tsx | 899 +++--- .../components/StageNode/StageNode.types.ts | 72 +- .../StageNode/StageNode.utils.test.ts | 570 ---- .../components/StageNode/StageNode.utils.ts | 208 -- .../StageNode/StageNodeTaskUtilities.ts | 8 +- .../canvas/components/StageNode/TaskMenu.tsx | 70 +- .../src/canvas/components/StageNode/index.ts | 4 + .../TaskNode/PlaceholderTaskNode.tsx | 33 + .../components/TaskNode/TaskNode.stories.tsx | 526 ++++ .../canvas/components/TaskNode/TaskNode.tsx | 204 ++ .../components/TaskNode/TaskNode.types.ts | 98 + .../components/TaskNode/TaskNodeContext.tsx | 103 + .../src/canvas/components/TaskNode/index.ts | 25 + .../TaskNode/taskReorderUtils.test.ts | 159 + .../components/TaskNode/taskReorderUtils.ts | 329 ++ .../components/TaskNode/useTaskPositions.ts | 227 ++ .../src/canvas/core/TaskTypeRegistry.ts | 231 ++ .../apollo-react/src/canvas/core/index.ts | 2 + .../src/canvas/core/useTaskTypeRegistry.ts | 67 + .../canvas/hooks/CrossStageDragContext.tsx | 21 + .../hooks/calculateTaskDropPosition.test.ts | 883 ++++++ .../canvas/hooks/calculateTaskDropPosition.ts | 398 +++ .../apollo-react/src/canvas/hooks/index.ts | 3 + .../src/canvas/hooks/useCrossStageTaskDrag.ts | 597 ++++ .../src/canvas/hooks/useTaskCopyPaste.ts | 249 ++ .../apollo-react/src/canvas/schema/index.ts | 1 + .../canvas/schema/task-definition/index.ts | 1 + .../schema/task-definition/task-manifest.ts | 117 + 35 files changed, 7166 insertions(+), 3031 deletions(-) create mode 100644 packages/apollo-react/src/canvas/components/StageNode/CrossStageTaskDrag.stories.tsx delete mode 100644 packages/apollo-react/src/canvas/components/StageNode/DraggableTask.test.tsx delete mode 100644 packages/apollo-react/src/canvas/components/StageNode/DraggableTask.tsx delete mode 100644 packages/apollo-react/src/canvas/components/StageNode/DraggableTask.types.ts create mode 100644 packages/apollo-react/src/canvas/components/StageNode/DropPlaceholder.tsx delete mode 100644 packages/apollo-react/src/canvas/components/StageNode/StageNode.utils.test.ts delete mode 100644 packages/apollo-react/src/canvas/components/StageNode/StageNode.utils.ts create mode 100644 packages/apollo-react/src/canvas/components/TaskNode/PlaceholderTaskNode.tsx create mode 100644 packages/apollo-react/src/canvas/components/TaskNode/TaskNode.stories.tsx create mode 100644 packages/apollo-react/src/canvas/components/TaskNode/TaskNode.tsx create mode 100644 packages/apollo-react/src/canvas/components/TaskNode/TaskNode.types.ts create mode 100644 packages/apollo-react/src/canvas/components/TaskNode/TaskNodeContext.tsx create mode 100644 packages/apollo-react/src/canvas/components/TaskNode/index.ts create mode 100644 packages/apollo-react/src/canvas/components/TaskNode/taskReorderUtils.test.ts create mode 100644 packages/apollo-react/src/canvas/components/TaskNode/taskReorderUtils.ts create mode 100644 packages/apollo-react/src/canvas/components/TaskNode/useTaskPositions.ts create mode 100644 packages/apollo-react/src/canvas/core/TaskTypeRegistry.ts create mode 100644 packages/apollo-react/src/canvas/core/useTaskTypeRegistry.ts create mode 100644 packages/apollo-react/src/canvas/hooks/CrossStageDragContext.tsx create mode 100644 packages/apollo-react/src/canvas/hooks/calculateTaskDropPosition.test.ts create mode 100644 packages/apollo-react/src/canvas/hooks/calculateTaskDropPosition.ts create mode 100644 packages/apollo-react/src/canvas/hooks/useCrossStageTaskDrag.ts create mode 100644 packages/apollo-react/src/canvas/hooks/useTaskCopyPaste.ts create mode 100644 packages/apollo-react/src/canvas/schema/task-definition/index.ts create mode 100644 packages/apollo-react/src/canvas/schema/task-definition/task-manifest.ts diff --git a/packages/apollo-react/src/canvas/components/StageNode/CrossStageTaskDrag.stories.tsx b/packages/apollo-react/src/canvas/components/StageNode/CrossStageTaskDrag.stories.tsx new file mode 100644 index 000000000..473199957 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/StageNode/CrossStageTaskDrag.stories.tsx @@ -0,0 +1,565 @@ +/** + * Cross-Stage Task Drag Stories + * + * Demonstrates dragging tasks between stages using React Flow nodes. + * - StageNode: Container-only stage nodes (no direct task rendering) + * - TaskNode: Separate React Flow nodes with parentId pointing to stage + * - useCrossStageTaskDrag: Hook for cross-stage drag detection and handling + */ + +import type { Meta, StoryObj } from '@storybook/react'; +import type { Node } from '@uipath/apollo-react/canvas/xyflow/react'; +import { + ConnectionMode, + Panel, + ReactFlowProvider, + useEdgesState, + useNodesState, +} from '@uipath/apollo-react/canvas/xyflow/react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { CrossStageDragProvider } from '../../hooks/CrossStageDragContext'; +import { + type TaskCopyParams, + type TaskMoveParams, + useCrossStageTaskDrag, +} from '../../hooks/useCrossStageTaskDrag'; +import { type TaskPasteParams, useTaskCopyPaste } from '../../hooks/useTaskCopyPaste'; +import { DefaultCanvasTranslations } from '../../types'; +import { BaseCanvas } from '../BaseCanvas'; +import { CanvasPositionControls } from '../CanvasPositionControls'; +import { TaskIcon, TaskItemTypeValues } from '../TaskIcon'; +import { PlaceholderTaskNode, TaskNode as TaskNodeComponent } from '../TaskNode'; +import type { TaskNode, TaskNodeData } from '../TaskNode/TaskNode.types'; +import { + insertTaskAtPosition, + moveTaskBetweenStages, + moveTaskWithinStage, +} from '../TaskNode/taskReorderUtils'; +import { calculateTaskPositions } from '../TaskNode/useTaskPositions'; +import { StageConnectionEdge } from './StageConnectionEdge'; +import { StageEdge } from './StageEdge'; +import { StageNode } from './StageNode'; +import type { StageNodeProps } from './StageNode.types'; + +const meta: Meta = { + title: 'Canvas/StageNode', + parameters: { + layout: 'fullscreen', + }, +}; + +export default meta; +type Story = StoryObj; + +// Wrapper for StageNode that spreads data props +const StageNodeWrapper = (props: any) => { + return ; +}; + +// Wrapper for TaskNode that spreads data props +const TaskNodeWrapper = (props: any) => { + return ; +}; + +// Initial task data for stages +interface StageTaskData { + taskIds: string[][]; + tasks: Record; +} + +const initialStageData: Record = { + 'stage-1': { + taskIds: [ + ['task-1'], + ['task-2', 'task-6'], // First parallel group + ['task-3', 'task-4'], // Second parallel group + ['task-5'], + ], + tasks: { + 'task-1': { label: 'KYC Verification', taskType: 'uipath.case-management.run-human-action' }, + 'task-2': { label: 'Document Review', taskType: 'uipath.case-management.process' }, + 'task-6': { label: 'Credit Check', taskType: 'uipath.case-management.rpa' }, + 'task-3': { label: 'Address Check', taskType: 'uipath.case-management.rpa' }, + 'task-4': { label: 'Property Check', taskType: 'uipath.case-management.rpa' }, + 'task-5': { label: 'Final Approval', taskType: 'uipath.case-management.agent' }, + }, + }, + 'stage-2': { + taskIds: [['task-7'], ['task-8']], + tasks: { + 'task-7': { label: 'Report Generation', taskType: 'uipath.case-management.api-workflow' }, + 'task-8': { label: 'Send Notification', taskType: 'uipath.case-management.action' }, + }, + }, + 'stage-3': { + taskIds: [], + tasks: {}, + }, +}; + +// Helper to get task icon based on type +function getTaskIcon(taskType: string): React.ReactElement { + if (taskType.includes('human-action')) { + return ; + } + if (taskType.includes('rpa')) { + return ; + } + if (taskType.includes('agent')) { + return ; + } + if (taskType.includes('api-workflow')) { + return ; + } + return ; +} + +// Create task nodes from stage data +function createTaskNodes( + stageId: string, + stageData: StageTaskData, + stageWidth: number +): TaskNode[] { + const positions = calculateTaskPositions(stageData.taskIds, stageWidth); + const nodes: TaskNode[] = []; + + stageData.taskIds.forEach((group, groupIndex) => { + group.forEach((taskId, taskIndex) => { + const taskInfo = stageData.tasks[taskId]; + if (!taskInfo) return; + + const position = positions.get(taskId); + if (!position) return; + + nodes.push({ + id: taskId, + type: 'task', + parentId: stageId, + // Note: extent:'parent' removed to allow cross-stage dragging + position: { x: position.x, y: position.y }, + width: position.width, // Set explicit width on React Flow node + data: { + taskType: taskInfo.taskType, + label: taskInfo.label, + iconElement: getTaskIcon(taskInfo.taskType), + groupIndex, + taskIndex, + width: position.width, // Also pass through data for component + } as TaskNodeData, + }); + }); + }); + + return nodes; +} + +// Main story component +const CrossStageTaskDragDemo = () => { + const [stageData, setStageData] = useState(initialStageData); + const [dragInfo, setDragInfo] = useState(''); + const [selectedTaskId, setSelectedTaskId] = useState(null); + const [selectedStageId, setSelectedStageId] = useState(null); + + // Create stage nodes + const stageNodes: Node[] = useMemo( + () => [ + { + id: 'stage-1', + type: 'stageV2', + position: { x: 48, y: 96 }, + style: { width: 304 }, + data: { + nodeType: 'case-management:Stage', + stageDetails: { + label: 'Processing', + taskIds: stageData['stage-1']?.taskIds ?? [], + }, + onTaskClick: (taskId: string) => { + setSelectedTaskId(taskId); + setSelectedStageId('stage-1'); + }, + } as Partial, + }, + { + id: 'stage-2', + type: 'stageV2', + position: { x: 400, y: 96 }, + style: { width: 304 }, + data: { + nodeType: 'case-management:Stage', + stageDetails: { + label: 'Review', + taskIds: stageData['stage-2']?.taskIds ?? [], + }, + onTaskClick: (taskId: string) => { + setSelectedTaskId(taskId); + setSelectedStageId('stage-2'); + }, + } as Partial, + }, + { + id: 'stage-3', + type: 'stageV2', + position: { x: 752, y: 96 }, + style: { width: 304 }, + data: { + nodeType: 'case-management:Stage', + stageDetails: { + label: 'Closing (Drop Here)', + taskIds: stageData['stage-3']?.taskIds ?? [], + defaultContent: 'Drag a task here', + }, + onTaskClick: (taskId: string) => { + setSelectedTaskId(taskId); + setSelectedStageId('stage-3'); + }, + onStageClick: () => setSelectedStageId('stage-3'), + } as Partial, + }, + ], + [stageData] + ); + + // Create task nodes for all stages + const taskNodes: TaskNode[] = useMemo(() => { + const nodes: TaskNode[] = []; + for (const stageId of Object.keys(stageData)) { + const data = stageData[stageId]; + if (data) { + nodes.push(...createTaskNodes(stageId, data, 304)); + } + } + return nodes; + }, [stageData]); + + // Combine stage and task nodes + const allNodes = useMemo(() => [...stageNodes, ...taskNodes], [stageNodes, taskNodes]); + + const [nodes, setNodes, onNodesChange] = useNodesState(allNodes); + const [edges, _setEdges, onEdgesChange] = useEdgesState([ + { + id: 'e1', + type: 'stage', + source: 'stage-1', + sourceHandle: 'stage-1____source____right', + target: 'stage-2', + targetHandle: 'stage-2____target____left', + }, + { + id: 'e2', + type: 'stage', + source: 'stage-2', + sourceHandle: 'stage-2____source____right', + target: 'stage-3', + targetHandle: 'stage-3____target____left', + }, + ]); + + // Update nodes when stageData changes + useEffect(() => { + setNodes(allNodes); + }, [allNodes, setNodes]); + + // Handle task move + const handleTaskMove = useCallback((params: TaskMoveParams) => { + const { taskId, sourceStageId, targetStageId, position } = params; + setDragInfo( + `Moved ${taskId} from ${sourceStageId} to ${targetStageId} at group ${position.groupIndex}${position.isParallel ? ' (parallel)' : ''}` + ); + + setStageData((prev) => { + const sourceStage = prev[sourceStageId]; + const targetStage = prev[targetStageId]; + if (!sourceStage?.tasks || !sourceStage?.taskIds || !targetStage) return prev; + + const taskInfo = sourceStage.tasks[taskId]; + if (!taskInfo) return prev; + + // Use utility functions for taskIds manipulation + const { sourceTaskIds: newSourceTaskIds, targetTaskIds: newTargetTaskIds } = + moveTaskBetweenStages(sourceStage.taskIds, targetStage.taskIds || [], taskId, position); + + // Update task metadata + const newSourceTasks = { ...sourceStage.tasks }; + delete newSourceTasks[taskId]; + + return { + ...prev, + [sourceStageId]: { + ...sourceStage, + taskIds: newSourceTaskIds, + tasks: newSourceTasks, + }, + [targetStageId]: { + ...targetStage, + taskIds: newTargetTaskIds, + tasks: { ...(targetStage.tasks || {}), [taskId]: taskInfo }, + }, + }; + }); + }, []); + + // Handle task copy + const handleTaskCopy = useCallback((params: TaskCopyParams) => { + const { taskId, newTaskId, sourceStageId, targetStageId, position } = params; + setDragInfo( + `Copied ${taskId} to ${newTaskId} in ${targetStageId} at group ${position.groupIndex}${position.isParallel ? ' (parallel)' : ''}` + ); + + setStageData((prev) => { + const sourceStage = prev[sourceStageId]; + const targetStage = prev[targetStageId]; + if (!sourceStage?.tasks || !targetStage) return prev; + + const taskInfo = sourceStage.tasks[taskId]; + if (!taskInfo) return prev; + + // Use utility function to insert copied task + const newTargetTaskIds = insertTaskAtPosition(targetStage.taskIds || [], newTaskId, position); + + return { + ...prev, + [targetStageId]: { + ...targetStage, + taskIds: newTargetTaskIds, + tasks: { + ...(targetStage.tasks || {}), + [newTaskId]: { ...taskInfo, label: `${taskInfo.label} (Copy)` }, + }, + }, + }; + }); + }, []); + + // Handle same-stage reorder + const handleTaskReorder = useCallback((params: any) => { + const { taskId, stageId, position } = params; + setDragInfo( + `Reordered ${taskId} in ${stageId} to group ${position.groupIndex}${position.isParallel ? ' (parallel)' : ''}` + ); + + setStageData((prev: any) => { + const stage = prev[stageId]; + if (!stage) return prev; + + // Use utility function for reordering + const newTaskIds = moveTaskWithinStage(stage.taskIds, taskId, position); + + return { + ...prev, + [stageId]: { + ...stage, + taskIds: newTaskIds, + }, + }; + }); + }, []); + + // Handle task paste (from keyboard shortcut) + const handleTaskPaste = useCallback((params: TaskPasteParams) => { + const { newTaskId, originalData, targetStageId, position } = params; + setDragInfo(`Pasted task ${newTaskId} to ${targetStageId} at group ${position.groupIndex}`); + + setStageData((prev) => { + const newData = { ...prev }; + + // Get target stage data + const targetStage = newData[targetStageId]; + if (!targetStage) return prev; + + // Add pasted task to target at position + const newTargetTaskIds = [...(targetStage.taskIds || [])]; + if (position.groupIndex >= newTargetTaskIds.length) { + newTargetTaskIds.push([newTaskId]); + } else { + newTargetTaskIds.splice(position.groupIndex, 0, [newTaskId]); + } + + newData[targetStageId] = { + ...targetStage, + taskIds: newTargetTaskIds, + tasks: { + ...(targetStage.tasks || {}), + [newTaskId]: { + label: `${originalData.label} (Pasted)`, + taskType: originalData.taskType as string, + }, + }, + }; + + return newData; + }); + }, []); + + // Get target task IDs for paste operation + const targetTaskIds = useMemo(() => { + if (!selectedStageId) return []; + return stageData[selectedStageId]?.taskIds || []; + }, [selectedStageId, stageData]); + + // Node and edge types + const nodeTypes = useMemo( + () => ({ + stageV2: StageNodeWrapper, + task: TaskNodeWrapper, + placeholder: (props: any) => , + }), + [] + ); + const edgeTypes = useMemo(() => ({ stage: StageEdge }), []); + + return ( +
+ + + {dragInfo && ( + +
+ {dragInfo} +
+
+ )} + +
+ Instructions: +
    +
  • Click a task to select it
  • +
  • Drag tasks between stages
  • +
  • Hold Alt/Cmd while dragging to copy
  • +
  • Ctrl/Cmd+C to copy selected task
  • +
  • Ctrl/Cmd+V to paste (click stage first)
  • +
+
+
+
+
+ ); +}; + +// Inner component that uses React Flow hooks +interface CrossStageTaskDragCanvasProps { + nodes: Node[]; + edges: any[]; + onNodesChange: any; + onEdgesChange: any; + nodeTypes: any; + edgeTypes: any; + onTaskMove: (params: TaskMoveParams) => void; + onTaskCopy: (params: TaskCopyParams) => void; + onTaskPaste: (params: TaskPasteParams) => void; + onTaskReorder: (params: any) => void; + selectedTaskId: string | null; + selectedStageId: string | null; + targetTaskIds: string[][]; +} + +const CrossStageTaskDragCanvas = ({ + nodes, + edges, + onNodesChange, + onEdgesChange, + nodeTypes, + edgeTypes, + onTaskMove, + onTaskCopy, + onTaskPaste, + onTaskReorder, + selectedTaskId, + selectedStageId, + targetTaskIds, +}: CrossStageTaskDragCanvasProps) => { + // Use the cross-stage drag hook + const { dragState, handlers } = useCrossStageTaskDrag({ + onTaskMove, + onTaskCopy, + onTaskReorder, + }); + + // Use the copy/paste hook (task shifting now handled in useCrossStageTaskDrag) + useTaskCopyPaste( + { onTaskPaste }, + { + selectedTaskId, + targetStageId: selectedStageId, + targetTaskIds, + enabled: true, + } + ); + + return ( + + + + + + {dragState.isDragging && ( + +
+ {dragState.isCopyMode ? 'Copy Mode (Alt/Cmd)' : 'Move Mode'} + {dragState.targetStageId !== dragState.sourceStageId && ( +
Target: {dragState.targetStageId}
+ )} +
+
+ )} +
+
+ ); +}; + +export const DragAndDrop: Story = { + name: 'Drag and Drop', + render: () => , +}; diff --git a/packages/apollo-react/src/canvas/components/StageNode/DraggableTask.test.tsx b/packages/apollo-react/src/canvas/components/StageNode/DraggableTask.test.tsx deleted file mode 100644 index c1d27c6a6..000000000 --- a/packages/apollo-react/src/canvas/components/StageNode/DraggableTask.test.tsx +++ /dev/null @@ -1,371 +0,0 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; -import type { NodeMenuItem } from '../NodeContextMenu'; -import { DraggableTask } from './DraggableTask'; -import type { DraggableTaskProps } from './DraggableTask.types'; - -// Mock dnd-kit -vi.mock('@dnd-kit/sortable', () => ({ - useSortable: () => ({ - attributes: {}, - listeners: {}, - setNodeRef: vi.fn(), - transition: undefined, - transform: null, - isDragging: false, - }), -})); - -const createTask = (id: string, label?: string) => ({ - id, - label: label ?? `Task ${id}`, -}); - -const createMenuItems = (onRemoveClick: () => void): NodeMenuItem[] => [ - { - id: 'move-up', - label: 'Move Up', - onClick: vi.fn(), - }, - { - id: 'move-down', - label: 'Move Down', - onClick: vi.fn(), - }, - { - type: 'divider' as const, - }, - { - id: 'remove-task', - label: 'Remove task from stage', - onClick: onRemoveClick, - }, -]; - -const defaultProps: DraggableTaskProps = { - task: createTask('task-1', 'Test Task'), - taskExecution: undefined, - isSelected: false, - isParallel: false, - contextMenuItems: [], - onTaskClick: vi.fn(), - isDragDisabled: false, - zoom: 1, -}; - -describe('DraggableTask', () => { - describe('Menu Button Rendering', () => { - it('renders menu button with correct testid when onMenuOpen is provided', () => { - const onMenuOpen = vi.fn(); - const onRemove = vi.fn(); - const menuItems = createMenuItems(onRemove); - - render( - - ); - - const menuButton = screen.getByTestId('stage-task-menu-task-1'); - expect(menuButton).toBeInTheDocument(); - }); - - it('renders menu button when contextMenuItems are provided', () => { - const onRemove = vi.fn(); - const menuItems = createMenuItems(onRemove); - - render(); - - const menuButton = screen.getByTestId('stage-task-menu-task-1'); - expect(menuButton).toBeInTheDocument(); - }); - - it('does not render menu button when contextMenuItems is empty and onMenuOpen is not provided', () => { - render(); - - const menuButton = screen.queryByTestId('stage-task-menu-task-1'); - expect(menuButton).not.toBeInTheDocument(); - }); - - it('renders menu button with icon', () => { - const onMenuOpen = vi.fn(); - const onRemove = vi.fn(); - const menuItems = createMenuItems(onRemove); - - render( - - ); - - const menuButton = screen.getByTestId('stage-task-menu-task-1'); - expect(menuButton).toBeInTheDocument(); - // Button should be rendered as a button element - expect(menuButton.tagName).toBe('BUTTON'); - }); - }); - - describe('Menu Opening and Closing', () => { - it('opens menu when button is clicked', async () => { - const user = userEvent.setup(); - const onMenuOpen = vi.fn(); - const onRemove = vi.fn(); - const menuItems = createMenuItems(onRemove); - - render( - - ); - - const menuButton = screen.getByTestId('stage-task-menu-task-1'); - await user.click(menuButton); - - // Menu should open - check for menu items - await waitFor(() => { - expect(screen.getByText('Move Up')).toBeInTheDocument(); - }); - }); - - it('does not trigger task click when menu button is clicked', async () => { - const user = userEvent.setup(); - const onTaskClick = vi.fn(); - const onMenuOpen = vi.fn(); - const onRemove = vi.fn(); - const menuItems = createMenuItems(onRemove); - - render( - - ); - - const menuButton = screen.getByTestId('stage-task-menu-task-1'); - await user.click(menuButton); - - // Task click should NOT be called - expect(onTaskClick).not.toHaveBeenCalled(); - }); - - it('prevents task selection when menu is open', async () => { - const user = userEvent.setup(); - const onTaskClick = vi.fn(); - const onMenuOpen = vi.fn(); - const onRemove = vi.fn(); - const menuItems = createMenuItems(onRemove); - - render( - - ); - - // Open menu - const menuButton = screen.getByTestId('stage-task-menu-task-1'); - await user.click(menuButton); - - await waitFor(() => { - expect(screen.getByText('Move Up')).toBeInTheDocument(); - }); - - // Try to click on task - const task = screen.getByTestId('stage-task-task-1'); - await user.click(task); - - // Task click should still not be called (menu is open) - expect(onTaskClick).not.toHaveBeenCalled(); - }); - - it('prevents task selection when menu is open', async () => { - const user = userEvent.setup(); - const onTaskClick = vi.fn(); - const onMenuOpen = vi.fn(); - const onRemove = vi.fn(); - const menuItems = createMenuItems(onRemove); - - render( - - ); - - // Open menu first - const menuButton = screen.getByTestId('stage-task-menu-task-1'); - await user.click(menuButton); - - await waitFor(() => { - expect(screen.getByText('Move Up')).toBeInTheDocument(); - }); - - // Try to click on task while menu is open - const task = screen.getByTestId('stage-task-task-1'); - await user.click(task); - - // Task click should not be called (menu is open) - expect(onTaskClick).not.toHaveBeenCalled(); - }); - }); - - describe('Menu Item Interaction', () => { - it('triggers menu item onClick when menu item is clicked', async () => { - const user = userEvent.setup(); - const onMenuOpen = vi.fn(); - const onRemove = vi.fn(); - const menuItems = createMenuItems(onRemove); - - render( - - ); - - // Open menu - const menuButton = screen.getByTestId('stage-task-menu-task-1'); - await user.click(menuButton); - - await waitFor(() => { - expect(screen.getByText('Remove task from stage')).toBeInTheDocument(); - }); - - // Click remove menu item - const removeMenuItem = screen.getByText('Remove task from stage'); - await user.click(removeMenuItem); - - // onRemove should be called - expect(onRemove).toHaveBeenCalledTimes(1); - }); - - it('closes menu after menu item is clicked', async () => { - const user = userEvent.setup(); - const onMenuOpen = vi.fn(); - const onRemove = vi.fn(); - const menuItems = createMenuItems(onRemove); - - render( - - ); - - // Open menu - const menuButton = screen.getByTestId('stage-task-menu-task-1'); - await user.click(menuButton); - - await waitFor(() => { - expect(screen.getByText('Move Up')).toBeInTheDocument(); - }); - - // Click a menu item - const moveUpItem = screen.getByText('Move Up'); - await user.click(moveUpItem); - - // Menu should close - await waitFor(() => { - expect(screen.queryByText('Move Up')).not.toBeInTheDocument(); - }); - }); - - it('includes divider in menu items', async () => { - const user = userEvent.setup(); - const onMenuOpen = vi.fn(); - const onRemove = vi.fn(); - const menuItems = createMenuItems(onRemove); - - render( - - ); - - // Open menu - const menuButton = screen.getByTestId('stage-task-menu-task-1'); - await user.click(menuButton); - - await waitFor(() => { - expect(screen.getByText('Move Up')).toBeInTheDocument(); - }); - - // Check that menu items before and after divider exist - expect(screen.getByText('Move Down')).toBeInTheDocument(); - expect(screen.getByText('Remove task from stage')).toBeInTheDocument(); - }); - }); - - describe('Task Click Behavior', () => { - it('calls onTaskClick when task is clicked and no menus are open', async () => { - const user = userEvent.setup(); - const onTaskClick = vi.fn(); - - render(); - - const task = screen.getByTestId('stage-task-task-1'); - await user.click(task); - - expect(onTaskClick).toHaveBeenCalledTimes(1); - expect(onTaskClick).toHaveBeenCalledWith(expect.any(Object), 'task-1'); - }); - - it('allows task click after menu is closed', async () => { - const user = userEvent.setup(); - const onTaskClick = vi.fn(); - const onMenuOpen = vi.fn(); - const onRemove = vi.fn(); - const menuItems = createMenuItems(onRemove); - - render( - - ); - - // Open menu - const menuButton = screen.getByTestId('stage-task-menu-task-1'); - await user.click(menuButton); - - await waitFor(() => { - expect(screen.getByText('Move Up')).toBeInTheDocument(); - }); - - // Click a menu item to close it - const moveUpItem = screen.getByText('Move Up'); - await user.click(moveUpItem); - - await waitFor(() => { - expect(screen.queryByText('Move Up')).not.toBeInTheDocument(); - }); - - // Now task click should work - const task = screen.getByTestId('stage-task-task-1'); - await user.click(task); - - expect(onTaskClick).toHaveBeenCalledWith(expect.any(Object), 'task-1'); - }); - }); - - describe('Task Rendering', () => { - it('renders task label', () => { - render(); - - expect(screen.getByText('Test Task')).toBeInTheDocument(); - }); - - it('renders with selected state', () => { - const { container } = render(); - - const task = screen.getByTestId('stage-task-task-1'); - expect(task).toBeInTheDocument(); - // The task should be rendered (selected state is applied via styled-components) - expect(container.querySelector('[data-testid="stage-task-task-1"]')).toBeInTheDocument(); - }); - - it('renders with parallel state', () => { - const { container } = render(); - - const task = screen.getByTestId('stage-task-task-1'); - expect(task).toBeInTheDocument(); - // The task should be rendered (parallel state is applied via styled-components) - expect(container.querySelector('[data-testid="stage-task-task-1"]')).toBeInTheDocument(); - }); - }); -}); diff --git a/packages/apollo-react/src/canvas/components/StageNode/DraggableTask.tsx b/packages/apollo-react/src/canvas/components/StageNode/DraggableTask.tsx deleted file mode 100644 index be7e7d4a0..000000000 --- a/packages/apollo-react/src/canvas/components/StageNode/DraggableTask.tsx +++ /dev/null @@ -1,233 +0,0 @@ -import { useSortable } from '@dnd-kit/sortable'; -import { CSS } from '@dnd-kit/utilities'; -import { FontVariantToken, Padding, Spacing } from '@uipath/apollo-core'; -import { Column, Row } from '@uipath/apollo-react/canvas/layouts'; -import { - ApBadge, - ApTooltip, - ApTypography, - BadgeSize, - type StatusTypes, -} from '@uipath/apollo-react/material'; -import { memo, useCallback, useMemo, useRef, useState } from 'react'; -import { ExecutionStatusIcon } from '../ExecutionStatusIcon'; -import type { DraggableTaskProps, TaskContentProps } from './DraggableTask.types'; -import { - INDENTATION_WIDTH, - StageTask, - StageTaskDragPlaceholder, - StageTaskDragPlaceholderWrapper, - StageTaskIcon, - StageTaskRetryDuration, - StageTaskWrapper, -} from './StageNode.styles'; -import type { StageTaskExecution } from './StageNode.types'; -import { TaskMenu, type TaskMenuHandle } from './TaskMenu'; - -const ProcessNodeIcon = () => ( - - - - - - -); - -const generateBadgeText = (taskExecution: StageTaskExecution) => { - if (!taskExecution.badge) { - return undefined; - } - if (taskExecution.retryCount && taskExecution.retryCount > 1) { - return `${taskExecution.badge} x${taskExecution.retryCount}`; - } - return taskExecution.badge; -}; - -export const TaskContent = memo(({ task, taskExecution, isDragging }: TaskContentProps) => ( - <> - - - {/* disable tooltip when dragging to avoid tooltip flickering */} - - {task.icon ?? } - - - {task.label} - - - - {taskExecution?.status && - (taskExecution.message ? ( - - - - ) : ( - - ))} - - {taskExecution && - (taskExecution.duration || taskExecution.retryDuration || taskExecution.badge) && ( - - - {taskExecution?.duration && ( - - {taskExecution.duration} - - )} - {taskExecution?.retryDuration && ( - - - {`(+${taskExecution.retryDuration})`} - - - )} - - {taskExecution?.badge && ( - - )} - - )} - - -)); - -const DraggableTaskComponent = ({ - task, - taskExecution, - isSelected, - isParallel, - contextMenuItems, - onTaskClick, - onMenuOpen, - isDragDisabled, - projectedDepth, - zoom = 1, -}: DraggableTaskProps) => { - const [isMenuOpen, setIsMenuOpen] = useState(false); - const taskRef = useRef(null); - const menuRef = useRef(null); - - const handleClick = useCallback( - (e: React.MouseEvent) => { - // If any menu is open, prevent task selection - if (isMenuOpen) { - return; - } - onTaskClick(e, task.id); - }, - [isMenuOpen, onTaskClick, task.id] - ); - - const handleMenuOpenChange = useCallback((isOpen: boolean) => { - setIsMenuOpen(isOpen); - }, []); - - const handleContextMenu = useCallback((e: React.MouseEvent) => { - menuRef.current?.handleContextMenu(e); - }, []); - - const { attributes, listeners, setNodeRef, transition, transform, isDragging } = useSortable({ - id: task.id, - disabled: isDragDisabled, - }); - - const style = useMemo(() => { - const scaledTransform = transform - ? { - ...transform, - x: transform.x / zoom, - y: transform.y / zoom, - } - : null; - - let marginLeft: string | undefined; - if (isDragging && projectedDepth !== undefined) { - if (projectedDepth === 1 && !isParallel) marginLeft = `${INDENTATION_WIDTH}px`; - else if (projectedDepth === 0 && isParallel) marginLeft = `-${INDENTATION_WIDTH}px`; - } - - return { - transition, - transform: CSS.Transform.toString(scaledTransform), - marginLeft, - }; - }, [transform, zoom, transition, isDragging, projectedDepth, isParallel]); - - if (isDragging) { - const isTargetParallel = projectedDepth === 1; - return ( - - - - ); - } - - const taskElement = ( - 0 && { onContextMenu: handleContextMenu })} - > - - - {contextMenuItems.length > 0 && ( - - )} - - ); - - if (isDragDisabled) { - return taskElement; - } - - return ( - - {taskElement} - - ); -}; - -export const DraggableTask = memo(DraggableTaskComponent); diff --git a/packages/apollo-react/src/canvas/components/StageNode/DraggableTask.types.ts b/packages/apollo-react/src/canvas/components/StageNode/DraggableTask.types.ts deleted file mode 100644 index 0e8f9801e..000000000 --- a/packages/apollo-react/src/canvas/components/StageNode/DraggableTask.types.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { NodeMenuItem } from '../NodeContextMenu'; -import type { StageTaskExecution, StageTaskItem } from './StageNode.types'; - -export interface TaskContentProps { - task: StageTaskItem; - taskExecution?: StageTaskExecution; - isDragging?: boolean; -} - -export interface DraggableTaskProps { - task: StageTaskItem; - taskExecution?: StageTaskExecution; - isSelected: boolean; - isParallel: boolean; - contextMenuItems: NodeMenuItem[]; - onTaskClick: (e: React.MouseEvent, taskId: string) => void; - onMenuOpen?: () => void; - isDragDisabled?: boolean; - projectedDepth?: number; - zoom?: number; -} diff --git a/packages/apollo-react/src/canvas/components/StageNode/DropPlaceholder.tsx b/packages/apollo-react/src/canvas/components/StageNode/DropPlaceholder.tsx new file mode 100644 index 000000000..84b3d22d8 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/StageNode/DropPlaceholder.tsx @@ -0,0 +1,124 @@ +/** + * DropPlaceholder - Visual indicator showing where a dragged task will drop + */ + +import { Spacing } from '@uipath/apollo-core'; +import { DEFAULT_TASK_POSITION_CONFIG } from '../TaskNode/useTaskPositions'; + +interface DropPlaceholderProps { + /** Group index where task will drop */ + groupIndex: number; + /** Task index within group */ + taskIndex: number; + /** Current task structure */ + taskIds: string[][]; + /** Whether dropping as parallel */ + isParallel: boolean; + /** ID of dragged task (to adjust position calculation) */ + draggedTaskId?: string | null; +} + +/** + * Calculate Y position for the drop placeholder + */ +function calculatePlaceholderY(groupIndex: number, taskIndex: number, taskIds: string[][]): number { + const config = DEFAULT_TASK_POSITION_CONFIG; + let y = config.headerHeight + config.contentPaddingTop; + + for (let gi = 0; gi < taskIds.length; gi++) { + const group = taskIds[gi]; + if (!group) continue; + + // If this is the target group + if (gi === groupIndex) { + // Add offset for tasks before the drop position + for (let ti = 0; ti < taskIndex && ti < group.length; ti++) { + y += config.taskHeight; + if (ti < group.length - 1) { + y += config.taskGap; + } + } + return y; + } + + // Add height of this entire group + y += group.length * config.taskHeight; + y += (group.length - 1) * config.taskGap; + y += config.taskGap; // Gap after group + } + + // Dropping after all groups + return y; +} + +export const DropPlaceholder = ({ + groupIndex, + taskIndex, + taskIds, + isParallel, + draggedTaskId, +}: DropPlaceholderProps) => { + // Adjust taskIds by removing dragged task (matches what task shifting does) + let adjustedTaskIds = taskIds; + let adjustedGroupIndex = groupIndex; + + if (draggedTaskId) { + const tempTaskIds = taskIds.map((group) => [...group]); + + // Remove dragged task + for (let gi = 0; gi < tempTaskIds.length; gi++) { + const group = tempTaskIds[gi]; + if (!group) continue; + const index = group.indexOf(draggedTaskId); + if (index !== -1) { + group.splice(index, 1); + // If removed task was before our target group, adjust groupIndex + if (gi < groupIndex) { + adjustedGroupIndex--; + } + break; + } + } + + // Filter empty groups + adjustedTaskIds = tempTaskIds.filter((g) => g && g.length > 0); + } + + const y = calculatePlaceholderY(adjustedGroupIndex, taskIndex, adjustedTaskIds); + const x = isParallel + ? DEFAULT_TASK_POSITION_CONFIG.contentPaddingX + DEFAULT_TASK_POSITION_CONFIG.parallelIndent + : DEFAULT_TASK_POSITION_CONFIG.contentPaddingX; + + const width = isParallel + ? `var(--stage-task-width-parallel, 216px)` + : `var(--stage-task-width, 246px)`; + + console.log('[DropPlaceholder] Rendering at', { + x, + y, + groupIndex: adjustedGroupIndex, + taskIndex, + isParallel, + draggedTaskId, + }); + + return ( +
+ ); +}; diff --git a/packages/apollo-react/src/canvas/components/StageNode/StageNode.stories.tsx b/packages/apollo-react/src/canvas/components/StageNode/StageNode.stories.tsx index 22a271203..15e230293 100644 --- a/packages/apollo-react/src/canvas/components/StageNode/StageNode.stories.tsx +++ b/packages/apollo-react/src/canvas/components/StageNode/StageNode.stories.tsx @@ -1,7 +1,15 @@ +/** + * StageNode Stories + * + * Demonstrates the StageNode component with TaskNodes rendered as separate React Flow nodes. + * The new StageNode uses taskIds: string[][] and requires tasks to be React Flow child nodes. + */ + import type { Meta, StoryObj } from '@storybook/react'; -import type { Connection, Edge } from '@uipath/apollo-react/canvas/xyflow/react'; +import type { Connection, Edge, Node, NodeChange } from '@uipath/apollo-react/canvas/xyflow/react'; import { addEdge, + applyNodeChanges, ConnectionMode, Panel, ReactFlowProvider, @@ -10,21 +18,40 @@ import { } from '@uipath/apollo-react/canvas/xyflow/react'; import { ApButton, ApMenu } from '@uipath/apollo-react/material/components'; import type { IMenuItem } from '@uipath/apollo-react/material/components/ap-menu/ApMenu.types'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { CrossStageDragProvider } from '../../hooks/CrossStageDragContext'; +import { + useCrossStageTaskDrag, + type TaskReorderParams, + type TaskMoveParams, + type TaskCopyParams, +} from '../../hooks/useCrossStageTaskDrag'; +import { useTaskCopyPaste, type TaskPasteParams } from '../../hooks/useTaskCopyPaste'; import { DefaultCanvasTranslations } from '../../types'; import { createGroupModificationHandlers, - type GroupModificationType, getHandlerForModificationType, + GroupModificationType, } from '../../utils/GroupModificationUtils'; import { BaseCanvas } from '../BaseCanvas'; import { CanvasPositionControls } from '../CanvasPositionControls'; +import type { NodeMenuItem } from '../NodeContextMenu'; import { TaskIcon, TaskItemTypeValues } from '../TaskIcon'; +import { PlaceholderTaskNode } from '../TaskNode/PlaceholderTaskNode'; +import { TaskNode } from '../TaskNode/TaskNode'; +import type { TaskNodeData, TaskNode as TaskNodeType } from '../TaskNode/TaskNode.types'; +import { + moveTaskWithinStage, + moveTaskBetweenStages, + insertTaskAtPosition, +} from '../TaskNode/taskReorderUtils'; +import { calculateTaskPositions } from '../TaskNode/useTaskPositions'; import type { ListItem } from '../Toolbox'; import { StageConnectionEdge } from './StageConnectionEdge'; import { StageEdge } from './StageEdge'; import { StageNode } from './StageNode'; -import type { StageNodeProps, StageTaskItem } from './StageNode.types'; +import type { StageNodeProps } from './StageNode.types'; +import { getContextMenuItems } from './StageNodeTaskUtilities'; const meta: Meta = { title: 'Canvas/StageNode', @@ -32,736 +59,1237 @@ const meta: Meta = { parameters: { layout: 'fullscreen', }, - decorators: [ - (Story, context) => { - // Allow stories to use custom render - if (context.parameters?.useCustomRender) { - return ; - } +}; + +export default meta; +type Story = StoryObj; + +// Wrapper components for React Flow +const StageNodeWrapper = (props: any) => { + return ; +}; - // Create a wrapper component that passes props correctly - const StageNodeWrapper = (props: any) => { - // React Flow passes node data in props.data, so we need to spread it - return ; +const TaskNodeWrapper = (props: any) => { + return ; +}; + +/** + * Merge computed nodes into React Flow's internal state. + * Preserves RF-managed properties (selected, dragging, measured dimensions) + * while updating data, style, and position from our computed nodes. + */ +function mergeNodes( + prevNodes: Node[], + computedNodes: Node[] +): Node[] { + const computedMap = new Map(computedNodes.map((n) => [n.id, n])); + const prevIds = new Set(prevNodes.map((n) => n.id)); + const computedIds = new Set(computedNodes.map((n) => n.id)); + + // Update existing nodes, preserving React Flow internal state + const merged = prevNodes + .filter((n) => computedIds.has(n.id)) + .map((n) => { + const source = computedMap.get(n.id); + if (!source) return n; + return { ...n, data: source.data, style: source.style, position: source.position }; + }); + + // Append new nodes that don't exist yet + for (const node of computedNodes) { + if (!prevIds.has(node.id)) { + merged.push(node); + } + } + + return merged; +} + +// Task info interface for story data +interface TaskInfo { + label: string; + taskType: string; + icon: React.ReactElement; + execution?: TaskNodeData['execution']; + contextMenuItems?: NodeMenuItem[]; +} + +// Helper to create task nodes from task IDs +interface CreateTaskNodesOptions { + stageId: string; + taskIds: string[][]; + tasks: Record; + stageWidth: number; + onTaskClick?: (taskId: string) => void; + onTaskSelect?: (taskId: string) => void; + stageExecution?: { duration?: string }; + enableCrossStage?: boolean; + onGroupModification?: ( + type: GroupModificationType, + groupIndex: number, + taskIndex: number + ) => void; + onRequestReplaceTask: (groupIndex: number, taskIndex: number) => void; +} + +function createTaskNodes({ + stageId, + taskIds, + tasks, + stageWidth, + onTaskClick, + onTaskSelect, + stageExecution, + enableCrossStage, + onGroupModification, + onRequestReplaceTask, +}: CreateTaskNodesOptions): TaskNodeType[] { + const positions = calculateTaskPositions(taskIds, stageWidth, tasks as any, stageExecution); + const nodes: TaskNodeType[] = []; + + taskIds.forEach((group, groupIndex) => { + group.forEach((taskId, taskIndex) => { + const taskInfo = tasks[taskId]; + if (!taskInfo) return; + + const position = positions.get(taskId); + if (!position) return; + + // Generate context menu items dynamically based on task position + const prevGroup = groupIndex > 0 ? taskIds[groupIndex - 1] : undefined; + const nextGroup = groupIndex < taskIds.length - 1 ? taskIds[groupIndex + 1] : undefined; + const contextMenuItems = onGroupModification + ? getContextMenuItems( + group.length > 1, // isParallelGroup + groupIndex, + taskIds.length, + taskIndex, + group.length, + prevGroup !== undefined && prevGroup.length > 1, // isAboveParallel + nextGroup !== undefined && nextGroup.length > 1, // isBelowParallel + onGroupModification, + onRequestReplaceTask + ) + : taskInfo.contextMenuItems; + + const node: TaskNodeType = { + id: taskId, + type: 'task', + parentId: stageId, + position: { x: position.x, y: position.y }, + width: position.width, + data: { + taskType: taskInfo.taskType, + label: taskInfo.label, + iconElement: taskInfo.icon, + groupIndex, + taskIndex, + execution: taskInfo.execution, + onTaskClick, + onTaskSelect, + width: position.width, + contextMenuItems, + } as TaskNodeData, }; - const initialNodes = context.parameters?.nodes || [ - { - id: '1', - type: 'stage', - position: { x: 250, y: 100 }, - data: { - stageDetails: context.args.stageDetails, - execution: context.args.execution, - addTaskLabel: context.args.addTaskLabel, - menuItems: context.args.menuItems, - onTaskAdd: context.args.onTaskAdd, - onTaskClick: context.args.onTaskClick, - }, + // Only add extent: 'parent' if cross-stage drag is disabled + if (!enableCrossStage) { + node.extent = 'parent'; + } + + nodes.push(node); + }); + }); + + return nodes; +} + +// Interactive canvas component with drag/copy/paste support +interface InteractiveStageCanvasProps { + stages: { + id: string; + label: string; + taskIds: string[][]; + position: { x: number; y: number }; + width?: number; + isException?: boolean; + isReadOnly?: boolean; + execution?: StageNodeProps['execution']; + }[]; + tasks: Record; + edges?: Edge[]; + showInstructions?: boolean; + onTaskAdd?: () => void; + onAddTaskFromToolbox?: (taskItem: ListItem) => void; + taskOptions?: ListItem[]; + enableTaskMenu?: boolean; + enableReplaceTask?: boolean; +} + +const InteractiveStageCanvas = ({ + stages: initialStages, + tasks: initialTasks, + edges: initialEdges = [], + showInstructions = true, + onTaskAdd, + onAddTaskFromToolbox, + taskOptions, + enableTaskMenu = true, + enableReplaceTask = true, +}: InteractiveStageCanvasProps) => { + const [stageTaskIds, setStageTaskIds] = useState>( + Object.fromEntries(initialStages.map((s) => [s.id, s.taskIds])) + ); + const [tasks, setTasks] = useState(initialTasks); + const [selectedTaskId, setSelectedTaskId] = useState(null); + const [actionLog, setActionLog] = useState(''); + const [replaceTargets, setReplaceTargets] = useState< + Record + >({}); + + // Handle replace task + const handleTaskReplace = useCallback( + (stageId: string) => (newTask: ListItem, groupIndex: number, taskIndex: number) => { + setActionLog( + `Replace task at stage ${stageId}, group ${groupIndex}, task ${taskIndex} with "${newTask.name}"` + ); + + const newTaskId = `replaced-${Date.now()}`; + const IconComp = newTask.icon?.Component; + setTasks((prev) => ({ + ...prev, + [newTaskId]: { + label: newTask.name, + taskType: newTask.data?.taskType || 'uipath.case-management.process', + icon: IconComp ? ( + + ) : ( + + ), }, - ]; + })); + + setStageTaskIds((prev) => { + const currentTaskIds = [...(prev[stageId] || [])].map((group) => [...group]); + const targetGroup = currentTaskIds[groupIndex]; + if (targetGroup && targetGroup[taskIndex] !== undefined) { + targetGroup[taskIndex] = newTaskId; + } + return { ...prev, [stageId]: currentTaskIds }; + }); + }, + [] + ); - const initialEdges = context.parameters?.edges || []; + // Create stage nodes + const stageNodes: Node[] = useMemo( + () => + initialStages.map((stage) => ({ + id: stage.id, + type: 'stage', + position: stage.position, + style: { width: stage.width || 304 }, + data: { + nodeType: 'case-management:Stage', + stageDetails: { + label: stage.label, + taskIds: stageTaskIds[stage.id] || [], + isException: stage.isException, + isReadOnly: stage.isReadOnly, + }, + execution: stage.execution, + onTaskClick: (taskId: string) => { + setSelectedTaskId(taskId); + setActionLog(`Selected: ${taskId}`); + }, + onTaskAdd, + onAddTaskFromToolbox, + taskOptions, + ...(enableReplaceTask && !stage.isReadOnly + ? { + onReplaceTaskFromToolbox: handleTaskReplace(stage.id), + replaceTaskTarget: replaceTargets[stage.id] ?? null, + onReplaceTaskTargetChange: ( + target: { groupIndex: number; taskIndex: number } | null + ) => setReplaceTargets((prev) => ({ ...prev, [stage.id]: target })), + } + : {}), + } as Partial, + })), + [ + initialStages, + stageTaskIds, + onTaskAdd, + onAddTaskFromToolbox, + taskOptions, + enableReplaceTask, + handleTaskReplace, + replaceTargets, + ] + ); - const [nodes, _setNodes, onNodesChange] = useNodesState(initialNodes); - const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); + // Handle group modification from context menu + const handleGroupModification = useCallback( + (stageId: string) => (type: GroupModificationType, groupIndex: number, taskIndex: number) => { + setActionLog( + `Group modification: ${type} at stage ${stageId}, group ${groupIndex}, task ${taskIndex}` + ); - const onConnect = useCallback( - (connection: Connection) => { - setEdges((eds) => addEdge(connection, eds)); + setStageTaskIds((prev) => { + const currentTaskIds = [...(prev[stageId] || [])].map((group) => [...group]); + const currentGroup = currentTaskIds[groupIndex]; + if (!currentGroup) return prev; + + switch (type) { + case GroupModificationType.TASK_GROUP_UP: { + if (groupIndex > 0) { + const taskId = currentGroup[taskIndex]; + if (!taskId) break; + // Remove from current group + currentGroup.splice(taskIndex, 1); + if (currentGroup.length === 0) { + currentTaskIds.splice(groupIndex, 1); + } + // Insert as new group before previous group + currentTaskIds.splice(groupIndex - 1, 0, [taskId]); + } + break; + } + case GroupModificationType.TASK_GROUP_DOWN: { + if (groupIndex < currentTaskIds.length - 1) { + const taskId = currentGroup[taskIndex]; + if (!taskId) break; + // Remove from current group + currentGroup.splice(taskIndex, 1); + if (currentGroup.length === 0) { + currentTaskIds.splice(groupIndex, 1); + // Insert after the (now shifted) next group + currentTaskIds.splice(groupIndex + 1, 0, [taskId]); + } else { + // Insert after the next group + currentTaskIds.splice(groupIndex + 2, 0, [taskId]); + } + } + break; + } + case GroupModificationType.MERGE_GROUP_UP: { + if (groupIndex > 0) { + const taskId = currentGroup[taskIndex]; + if (!taskId) break; + // Remove from current group + currentGroup.splice(taskIndex, 1); + if (currentGroup.length === 0) { + currentTaskIds.splice(groupIndex, 1); + } + // Add to previous group + currentTaskIds[groupIndex - 1]?.push(taskId); + } + break; + } + case GroupModificationType.MERGE_GROUP_DOWN: { + if (groupIndex < currentTaskIds.length - 1) { + const taskId = currentGroup[taskIndex]; + if (!taskId) break; + // Remove from current group + currentGroup.splice(taskIndex, 1); + const wasEmpty = currentGroup.length === 0; + if (wasEmpty) { + currentTaskIds.splice(groupIndex, 1); + } + // Add to next group (adjust index if we removed current group) + const nextGroupIndex = wasEmpty ? groupIndex : groupIndex + 1; + currentTaskIds[nextGroupIndex]?.push(taskId); + } + break; + } + case GroupModificationType.UNGROUP_ALL_TASKS: { + // Replace the group with individual groups + currentTaskIds.splice(groupIndex, 1, ...currentGroup.map((id) => [id])); + break; + } + case GroupModificationType.SPLIT_GROUP: { + const taskId = currentGroup[taskIndex]; + if (!taskId) break; + // Remove from current group + currentGroup.splice(taskIndex, 1); + // Insert as new group after current group + currentTaskIds.splice(groupIndex + 1, 0, [taskId]); + break; + } + case GroupModificationType.REMOVE_GROUP: { + currentTaskIds.splice(groupIndex, 1); + break; + } + case GroupModificationType.REMOVE_TASK: { + currentGroup.splice(taskIndex, 1); + if (currentGroup.length === 0) { + currentTaskIds.splice(groupIndex, 1); + } + break; + } + } + + return { ...prev, [stageId]: currentTaskIds }; + }); + }, + [] + ); + + // Create task nodes for all stages (cross-stage drag enabled by default) + const taskNodes = useMemo(() => { + const allTaskNodes: TaskNodeType[] = []; + initialStages.forEach((stage) => { + const stageTaskIdsArr = stageTaskIds[stage.id] || []; + const nodes = createTaskNodes({ + stageId: stage.id, + taskIds: stageTaskIdsArr, + tasks, + stageWidth: stage.width || 304, + onTaskClick: (taskId) => { + setSelectedTaskId(taskId); + setActionLog(`Selected: ${taskId}`); }, - [setEdges] - ); + onTaskSelect: setSelectedTaskId, + stageExecution: stage.execution?.stageStatus, + enableCrossStage: true, + onGroupModification: + enableTaskMenu && !stage.isReadOnly ? handleGroupModification(stage.id) : undefined, + onRequestReplaceTask: + enableReplaceTask && !stage.isReadOnly + ? (groupIndex, taskIndex) => + setReplaceTargets((prev) => ({ ...prev, [stage.id]: { groupIndex, taskIndex } })) + : () => {}, + }); + allTaskNodes.push(...nodes); + }); + return allTaskNodes; + }, [ + initialStages, + stageTaskIds, + tasks, + enableTaskMenu, + enableReplaceTask, + handleGroupModification, + ]); - const nodeTypes = useMemo(() => ({ stage: StageNodeWrapper }), [StageNodeWrapper]); - const edgeTypes = useMemo(() => ({ stage: StageEdge }), []); - const defaultEdgeOptions = useMemo(() => ({ type: 'stage' }), []); - - return ( -
- - - - - - - -
- ); + const allNodes = useMemo(() => [...stageNodes, ...taskNodes], [stageNodes, taskNodes]); + + const [nodes, setNodes, onNodesChange] = useNodesState(allNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); + + useEffect(() => { + setNodes((prev) => mergeNodes(prev, allNodes)); + }, [allNodes, setNodes]); + + const onConnect = useCallback( + (connection: Connection) => { + setEdges((eds) => addEdge({ ...connection, type: 'stage' }, eds)); }, - ], - args: { - stageDetails: { - label: 'Default Stage', - tasks: [], + [setEdges] + ); + + const nodeTypes = useMemo( + () => ({ + stage: StageNodeWrapper, + task: TaskNodeWrapper, + placeholder: (props: any) => , + }), + [] + ); + const edgeTypes = useMemo(() => ({ stage: StageEdge }), []); + + // Handle task reordering (within same stage) + const handleTaskReorder = useCallback((params: TaskReorderParams) => { + const { taskId, stageId, position } = params; + const depthLabel = position.isParallel ? 'parallel' : 'sequential'; + setActionLog(`Reordered ${taskId} to group ${position.groupIndex} (${depthLabel})`); + + setStageTaskIds((prev) => { + const currentTaskIds = prev[stageId || ''] || []; + const newTaskIds = moveTaskWithinStage(currentTaskIds, taskId, position); + return { ...prev, [stageId || '']: newTaskIds }; + }); + }, []); + + // Handle task move (cross-stage) + const handleTaskMove = useCallback( + (params: TaskMoveParams) => { + const { taskId, sourceStageId, targetStageId, position } = params; + setActionLog( + `Moved ${taskId} from ${sourceStageId} to ${targetStageId} at group ${position.groupIndex}${position.isParallel ? ' (parallel)' : ''}` + ); + + const taskInfo = tasks[taskId]; + if (!taskInfo) return; + + setStageTaskIds((prev) => { + const sourceTaskIds = prev[sourceStageId] || []; + const targetTaskIds = prev[targetStageId] || []; + + const { sourceTaskIds: newSourceTaskIds, targetTaskIds: newTargetTaskIds } = + moveTaskBetweenStages(sourceTaskIds, targetTaskIds, taskId, position); + + return { + ...prev, + [sourceStageId]: newSourceTaskIds, + [targetStageId]: newTargetTaskIds, + }; + }); }, - }, - argTypes: { - addTaskLabel: { - control: 'text', - description: 'Label for the add process button', - defaultValue: 'Add process', + [tasks] + ); + + // Handle task copy (cross-stage with Alt/Cmd) + const handleTaskCopy = useCallback( + (params: TaskCopyParams) => { + const { taskId, newTaskId, targetStageId, position } = params; + setActionLog( + `Copied ${taskId} to ${newTaskId} in ${targetStageId} at group ${position.groupIndex}${position.isParallel ? ' (parallel)' : ''}` + ); + + const taskInfo = tasks[taskId]; + if (!taskInfo) return; + + // Add copied task to tasks + setTasks((prev) => ({ + ...prev, + [newTaskId]: { + ...taskInfo, + label: `${taskInfo.label} (Copy)`, + }, + })); + + // Insert copied task at position + setStageTaskIds((prev) => { + const targetTaskIds = prev[targetStageId] || []; + const newTargetTaskIds = insertTaskAtPosition(targetTaskIds, newTaskId, position); + return { ...prev, [targetStageId]: newTargetTaskIds }; + }); }, - }, -} satisfies Meta; + [tasks] + ); -export default meta; -type Story = StoryObj; + // Handle task paste (keyboard shortcut) + const handleTaskPaste = useCallback((params: TaskPasteParams) => { + const { newTaskId, originalData, targetStageId } = params; + setActionLog(`Pasted as ${newTaskId}`); + + setTasks((prev) => ({ + ...prev, + [newTaskId]: { + label: `${originalData.label} (Copy)`, + taskType: originalData.taskType as string, + icon: originalData.iconElement ?? ( + + ), + execution: originalData.execution, + } as TaskInfo, + })); + + setStageTaskIds((prev) => ({ + ...prev, + [targetStageId]: [...(prev[targetStageId] || []), [newTaskId]], + })); + }, []); + + return ( + + + + ); +}; -// Create icon components -const ProcessIcon = () => ( - - - - - - -); - -const VerificationIcon = () => ( - - - - -); - -const DocumentIcon = () => ( - - - - - - -); - -// Example with both sequential and parallel tasks -const sampleTasks: StageTaskItem[][] = [ - [{ id: 'liability_check', label: 'Liability Check', icon: }], - [{ id: 'credit_review', label: 'Credit Review', icon: }], - // Parallel tasks - these run at the same time - [ +// Inner component that uses React Flow hooks +interface InteractiveCanvasInnerProps { + nodes: Node[]; + edges: Edge[]; + onNodesChange: any; + onEdgesChange: any; + onConnect: (connection: Connection) => void; + nodeTypes: any; + edgeTypes: any; + onTaskReorder: (params: TaskReorderParams) => void; + onTaskMove?: (params: TaskMoveParams) => void; + onTaskCopy?: (params: TaskCopyParams) => void; + onTaskPaste: (params: TaskPasteParams) => void; + selectedTaskId: string | null; + stageTaskIds: Record; + actionLog: string; + showInstructions: boolean; +} + +const InteractiveCanvasInner = ({ + nodes, + edges, + onNodesChange, + onEdgesChange, + onConnect, + nodeTypes, + edgeTypes, + onTaskReorder, + onTaskMove, + onTaskCopy, + onTaskPaste, + selectedTaskId, + stageTaskIds, + actionLog, + showInstructions, +}: InteractiveCanvasInnerProps) => { + const { dragState, handlers } = useCrossStageTaskDrag({ + onTaskReorder, + onTaskMove, + onTaskCopy, + }); + + // Find which stage the selected task is in + const selectedTaskStageId = useMemo(() => { + if (!selectedTaskId) return null; + for (const [stageId, taskIds] of Object.entries(stageTaskIds)) { + if (taskIds.flat().includes(selectedTaskId)) { + return stageId; + } + } + return null; + }, [selectedTaskId, stageTaskIds]); + + useTaskCopyPaste( + { onTaskPaste }, { - id: 'address_verification', - label: 'Address Verification and a really long label that might wrap', - icon: , - }, - { id: 'property_verification', label: 'Property Verification', icon: }, - ], - [{ id: 'processing_review', label: 'Processing Review', icon: }], -]; + selectedTaskId, + targetStageId: selectedTaskStageId || '', + targetTaskIds: stageTaskIds[selectedTaskStageId || ''] || [], + enabled: !!selectedTaskId, + } + ); + + return ( + + + + + + {showInstructions && ( + +
+ Instructions: +
    +
  • Click a task to select it
  • +
  • Drag tasks to reorder
  • +
  • Ctrl/Cmd+C to copy selected task
  • +
  • Ctrl/Cmd+V to paste task
  • +
+
+
+ )} + {actionLog && ( + +
+ {actionLog} +
+
+ )} +
+
+ ); +}; + +// ============================================================================ +// Stories +// ============================================================================ +/** + * Default stage with no tasks - shows empty state + */ export const Default: Story = { - name: 'Default', - parameters: { - nodes: [ - { - id: '0', - type: 'stage', - position: { x: 48, y: 96 }, - width: 304, - data: { - stageDetails: { - label: 'Application', - tasks: [], - }, - execution: { - stageStatus: { - duration: 'SLA: None', + render: () => { + const tasks: Record = {}; + + return ( +
+ { - window.alert('Add task functionality - this would open a dialog to add a new task'); - }, - }, - }, - { - id: '1', - type: 'stage', - position: { x: 400, y: 96 }, - width: 304, - data: { - stageDetails: { - label: 'Processing with a really really really long label that might wrap', - tasks: sampleTasks, - sla: '1h', - slaBreached: false, - escalation: '1h', - escalationsTriggered: false, - }, - execution: { - stageStatus: { - duration: 'SLA: None', + { + id: 'stage-2', + label: 'Processing with a really really really long label that might wrap', + taskIds: [], + position: { x: 400, y: 96 }, + width: 304, }, - }, - onAddTaskFromToolbox: (taskItem: ListItem) => { - window.alert( - `Add task (${taskItem.data.type}) - this would open a panel to configure the new task` - ); - }, - taskOptions: sampleTasks.flat().map((task) => ({ - id: task.id, - name: task.label, - icon: { Component: () => task.icon }, - data: { type: task.id }, - })), - }, - }, - ], + ]} + tasks={tasks} + showInstructions={false} + onTaskAdd={() => window.alert('Add task functionality')} + /> +
+ ); }, - args: {}, }; +/** + * Stage with task icons showing different task types + */ export const WithTaskIcons: Story = { - name: 'With Task Icons', - parameters: { - nodes: [ - { - id: '1', - type: 'stage', - position: { x: 48, y: 96 }, - width: 304, - data: { - stageDetails: { - label: 'Task Icons Demo', - tasks: [ - [ - { - id: 'human-task', - label: 'Human in the Loop', - icon: , - }, - ], - [ - { - id: 'agent-task', - label: 'Agent Task', - icon: , - }, - { - id: 'external-agent-task', - label: 'External Agent', - icon: , - }, - ], - [ - { - id: 'rpa-task', - label: 'RPA Automation', - icon: , - }, - { - id: 'api-task', - label: 'API Automation', - icon: , - }, - ], - [ - { - id: 'process-task', - label: 'Agentic Process', - icon: , - }, - { - id: 'connector-task', - label: 'Connector', - icon: , - }, - ], - [ - { - id: 'timer-task', - label: 'Timer', - icon: , - }, - ], - ], - }, - }, + render: () => { + const tasks: Record = { + 'agent-task': { + label: 'Agent Task', + taskType: 'uipath.case-management.agent', + icon: , }, - ], + 'rpa-task': { + label: 'RPA Automation', + taskType: 'uipath.case-management.rpa', + icon: , + }, + 'api-task': { + label: 'API Automation', + taskType: 'uipath.case-management.api-workflow', + icon: , + }, + 'human-task': { + label: 'Human in the Loop', + taskType: 'uipath.case-management.run-human-action', + icon: , + }, + 'process-task': { + label: 'Agentic Process', + taskType: 'uipath.case-management.process', + icon: , + }, + }; + + return ( +
+ +
+ ); }, - args: {}, }; +/** + * Stages showing execution status with completed, in-progress, and failed states + */ export const ExecutionStatus: Story = { - name: 'Execution Status', - parameters: { - nodes: [ - { - id: '0', - type: 'stage', - position: { x: 48, y: 96 }, - width: 304, - data: { - stageDetails: { - sla: '1h', - slaBreached: false, - escalation: '1h', - escalationsTriggered: false, - label: 'Application', - isReadOnly: true, - tasks: [ - [{ id: '1', label: 'KYC and AML Checks', icon: }], - [ - { - id: '2', - label: 'Document Verification is going to be very very really long', - icon: , - }, - ], - ], - }, - execution: { - stageStatus: { - status: 'Completed', - duration: 'SLA: 4h', - }, - taskStatus: { - '1': { status: 'Completed', label: 'KYC and AML Checks', duration: '2h 15m' }, - '2': { status: 'Completed', label: 'Document Verification', duration: '1h 45m' }, - }, - }, - }, + render: () => { + const tasks: Record = { + // Stage 1 tasks + 's1-task-1': { + label: 'KYC and AML Checks', + taskType: 'uipath.case-management.rpa', + icon: , + execution: { status: 'Completed', duration: '2h 15m' }, }, - { - id: '1', - type: 'stage', - position: { x: 400, y: 96 }, - width: 304, - data: { - stageDetails: { - sla: '1h', - slaBreached: true, - escalation: '1h', - escalationsTriggered: true, - label: 'Processing', - isReadOnly: true, - tasks: [ - [{ id: '1', label: 'Liability Check', icon: }], - [{ id: '2', label: 'Credit Review', icon: }], - [ - { id: '3', label: 'Address Verification', icon: }, - { id: '4', label: 'Property Verification', icon: }, - ], - [{ id: '5', label: 'Processing Review', icon: }], - ], - selectedTasks: ['2'], - }, - execution: { - stageStatus: { - status: 'Completed', - duration: 'SLA: 6h 15m', - }, - taskStatus: { - '1': { - status: 'Completed', - label: 'Liability Check', - duration: '1h 30m', - retryDuration: '25m', - badge: 'Reworked', - badgeStatus: 'error', - retryCount: 2, - }, - '2': { - status: 'Completed', - label: 'Credit Review', - duration: '1h 30m', - retryDuration: '32m', - badge: 'Reworked', - retryCount: 1, - }, - '3': { status: 'Completed', label: 'Address Verification', duration: '30m' }, - '4': { - status: 'Completed', - label: 'Property Verification', - duration: '1h 30m', - retryDuration: '1h 5m', - badge: 'Reworked', - retryCount: 3, - }, - '5': { status: 'Completed', label: 'Processing Review', duration: '1h 15m' }, - }, - }, - }, + 's1-task-2': { + label: 'Document Verification is going to be very very really long', + taskType: 'uipath.case-management.process', + icon: , + execution: { status: 'Completed', duration: '1h 45m' }, }, - { - id: '2', - type: 'stage', - position: { x: 752, y: 96 }, - width: 304, - data: { - stageDetails: { - label: 'Underwriting', - isReadOnly: true, - tasks: [ - [{ id: '1', label: 'Report Ordering', icon: }], - [{ id: '2', label: 'Underwriting Verification', icon: }], - ], - }, - onTaskClick: (id: string) => window.alert(`Task clicked: ${id}`), - execution: { - stageStatus: { - status: 'InProgress', - label: 'In progress', - duration: 'SLA: 2h 15m', - }, - taskStatus: { - '1': { status: 'Completed', label: 'Report Ordering', duration: '2h 15m' }, - '2': { status: 'InProgress', label: 'Underwriting Verification' }, - }, - }, + // Stage 2 tasks + 's2-task-1': { + label: 'Liability Check', + taskType: 'uipath.case-management.rpa', + icon: , + execution: { + status: 'Completed', + duration: '1h 30m', + retryDuration: '25m', + badge: 'Reworked', + badgeStatus: 'error', + retryCount: 2, }, }, - { - id: '3', - type: 'stage', - position: { x: 1104, y: 96 }, - width: 304, - data: { - stageDetails: { - label: 'Closing', - isReadOnly: true, - tasks: [ - [{ id: '1', label: 'Loan Packet Creation', icon: }], - [{ id: '2', label: 'Customer Signing', icon: }], - [{ id: '3', label: 'Generate Audit Report', icon: }], - ], - }, - execution: { - stageStatus: { - status: 'NotExecuted', - label: 'Not started', - }, - taskStatus: {}, - }, + 's2-task-2': { + label: 'Credit Review', + taskType: 'uipath.case-management.process', + icon: , + execution: { + status: 'Completed', + duration: '1h 30m', + retryDuration: '32m', + badge: 'Reworked', + retryCount: 1, }, }, - { - id: '4', - type: 'stage', - position: { x: 1104, y: 400 }, - width: 304, - data: { - stageDetails: { - label: 'Rejected', - isException: true, - isReadOnly: true, - tasks: [ - [{ id: '1', label: 'Customer Notification', icon: }], - [{ id: '2', label: 'Generate Audit Report', icon: }], - ], - }, - execution: { - stageStatus: { - status: 'NotExecuted', - label: 'Not started', - }, - taskStatus: {}, - }, + 's2-task-3': { + label: 'Address Verification', + taskType: 'uipath.case-management.rpa', + icon: , + execution: { status: 'Completed', duration: '30m' }, + }, + 's2-task-4': { + label: 'Property Verification', + taskType: 'uipath.case-management.rpa', + icon: , + execution: { + status: 'Completed', + duration: '1h 30m', + retryDuration: '1h 5m', + badge: 'Reworked', + retryCount: 3, }, }, - ], - edges: [ + 's2-task-5': { + label: 'Processing Review', + taskType: 'uipath.case-management.agent', + icon: , + execution: { status: 'Completed', duration: '1h 15m' }, + }, + // Stage 3 tasks + 's3-task-1': { + label: 'Report Ordering', + taskType: 'uipath.case-management.process', + icon: , + execution: { status: 'Completed', duration: '2h 15m' }, + }, + 's3-task-2': { + label: 'Underwriting Verification', + taskType: 'uipath.case-management.rpa', + icon: , + execution: { status: 'InProgress' }, + }, + // Stage 4 tasks + 's4-task-1': { + label: 'Loan Packet Creation', + taskType: 'uipath.case-management.process', + icon: , + execution: { status: 'NotExecuted' }, + }, + 's4-task-2': { + label: 'Customer Signing', + taskType: 'uipath.case-management.run-human-action', + icon: , + execution: { status: 'NotExecuted' }, + }, + 's4-task-3': { + label: 'Generate Audit Report', + taskType: 'uipath.case-management.process', + icon: , + execution: { status: 'NotExecuted' }, + }, + // Rejected stage tasks + 'rejected-task-1': { + label: 'Customer Notification', + taskType: 'uipath.case-management.agent', + icon: , + execution: { status: 'NotExecuted' }, + }, + 'rejected-task-2': { + label: 'Generate Audit Report', + taskType: 'uipath.case-management.process', + icon: , + execution: { status: 'NotExecuted' }, + }, + }; + + const edges: Edge[] = [ { id: 'e1', type: 'stage', - source: '0', - sourceHandle: '0____source____right', - target: '1', - targetHandle: '1____target____left', + source: 'stage-1', + sourceHandle: 'stage-1____source____right', + target: 'stage-2', + targetHandle: 'stage-2____target____left', }, { id: 'e2', type: 'stage', - source: '1', - sourceHandle: '1____source____right', - target: '2', - targetHandle: '2____target____left', + source: 'stage-2', + sourceHandle: 'stage-2____source____right', + target: 'stage-3', + targetHandle: 'stage-3____target____left', }, { id: 'e3', type: 'stage', - source: '2', - sourceHandle: '2____source____right', - target: '3', - targetHandle: '3____target____left', + source: 'stage-3', + sourceHandle: 'stage-3____source____right', + target: 'stage-4', + targetHandle: 'stage-4____target____left', }, - ] as Edge[], + ]; + + return ( +
+ +
+ ); }, - args: {}, }; +/** + * Interactive design mode with editable and read-only stages side by side + */ export const InteractiveTaskManagement: Story = { - name: 'Interactive Task Management', - parameters: { - nodes: [ - { - id: 'design-stage', - type: 'stage', - position: { x: 48, y: 96 }, - width: 352, - data: { - stageDetails: { - label: 'Design Mode - Editable', - tasks: [ - [{ id: '1', label: 'Initial Task', icon: }], - [ - { - id: '2', - label: - 'Credit Review with a very long label that will be truncated and show tooltip', - icon: , - }, - ], - [ - { id: '3', label: 'Address Verification', icon: }, - { - id: '4', - label: 'Property Verification with Long Name', - icon: , - }, - { id: '5', label: 'Background Check', icon: }, - ], - [ - { - id: '6', - label: 'Final Review Task with Extended Description', - icon: , - }, - ], - ], - }, - onTaskClick: (taskId: string) => { - window.alert(`Task clicked: ${taskId}`); - }, - onTaskRemove: (groupIndex: number, taskIndex: number) => { - window.alert( - `Task removal requested!\nGroup: ${groupIndex}\nTask: ${taskIndex}\n\nIn a real app, this would remove the task from the data.` - ); - }, - onTaskAdd: () => { - window.alert('Add task functionality - this would open a dialog to add a new task'); - }, + render: () => { + const tasks: Record = { + // Editable stage tasks + 'task-1': { + label: 'Initial Task', + taskType: 'uipath.case-management.rpa', + icon: , + }, + 'task-2': { + label: 'Credit Review with a very long label that will be truncated and show tooltip', + taskType: 'uipath.case-management.process', + icon: , + }, + 'task-3': { + label: 'Address Verification', + taskType: 'uipath.case-management.rpa', + icon: , + }, + 'task-4': { + label: 'Property Verification with Long Name', + taskType: 'uipath.case-management.rpa', + icon: , + }, + 'task-5': { + label: 'Background Check', + taskType: 'uipath.case-management.agent', + icon: , + }, + 'task-6': { + label: 'Final Review Task with Extended Description', + taskType: 'uipath.case-management.agent', + icon: , + }, + // Execution stage tasks + 'exec-task-1': { + label: 'Task with execution status and very long name that will be truncated', + taskType: 'uipath.case-management.rpa', + icon: , + execution: { + status: 'Completed', + duration: '30m', + badge: 'Completed', + badgeStatus: 'info', }, }, - { - id: 'execution-stage', - type: 'stage', - position: { x: 448, y: 96 }, - width: 352, - data: { - stageDetails: { - label: 'Execution Mode - Read Only', - isReadOnly: true, - tasks: [ - [ - { - id: '1', - label: 'Task with execution status and very long name that will be truncated', - icon: , - }, - ], - [{ id: '2', label: 'Credit Review Processing', icon: }], - [ - { - id: '3', - label: 'Parallel Address Verification Task', - icon: , - }, - { - id: '4', - label: 'Parallel Property Verification with Extended Name', - icon: , - }, - ], - [{ id: '5', label: 'Final Review and Approval Process', icon: }], - ], - }, - execution: { - stageStatus: { - status: 'InProgress', - label: 'In progress', - duration: '2h 15m', + 'exec-task-2': { + label: 'Credit Review Processing', + taskType: 'uipath.case-management.process', + icon: , + execution: { + status: 'InProgress', + duration: '1h 15m', + retryDuration: '15m', + badge: 'Retry', + badgeStatus: 'warning', + retryCount: 2, + }, + }, + 'exec-task-3': { + label: 'Parallel Address Verification Task', + taskType: 'uipath.case-management.rpa', + icon: , + execution: { status: 'Completed', duration: '45m' }, + }, + 'exec-task-4': { + label: 'Parallel Property Verification with Extended Name', + taskType: 'uipath.case-management.rpa', + icon: , + execution: { + status: 'Failed', + duration: '20m', + retryDuration: '10m', + badge: 'Error', + badgeStatus: 'error', + retryCount: 1, + }, + }, + 'exec-task-5': { + label: 'Final Review and Approval Process', + taskType: 'uipath.case-management.agent', + icon: , + execution: { status: 'NotExecuted' }, + }, + }; + + return ( +
+ { - window.alert(`Task clicked: ${taskId} (execution mode - read only)`); - }, - }, - }, - ], + ]} + tasks={tasks} + /> +
+ ); }, - args: {}, }; +/** + * Complete loan processing workflow with connected stages + */ export const LoanProcessingWorkflow: Story = { - name: 'Loan Processing Workflow', - parameters: { - nodes: [ - // Application Stage - { - id: 'application', - type: 'stage', - position: { x: 48, y: 96 }, - width: 304, - data: { - stageDetails: { - label: 'Application', - tasks: [ - [{ id: '1', label: 'KYC and AML Checks', icon: }], - [{ id: '2', label: 'Document Verification', icon: }], - ], - selectedTasks: ['1'], - }, - }, + render: () => { + const tasks: Record = { + // Application stage + 'app-task-1': { + label: 'KYC and AML Checks', + taskType: 'uipath.case-management.rpa', + icon: , + }, + 'app-task-2': { + label: 'Document Verification', + taskType: 'uipath.case-management.process', + icon: , + }, + // Processing stage + 'proc-task-1': { + label: 'Liability Check', + taskType: 'uipath.case-management.rpa', + icon: , + }, + 'proc-task-2': { + label: 'Credit Review', + taskType: 'uipath.case-management.process', + icon: , + }, + 'proc-task-3': { + label: 'Address Verification', + taskType: 'uipath.case-management.rpa', + icon: , + }, + 'proc-task-4': { + label: 'Property Verification', + taskType: 'uipath.case-management.rpa', + icon: , + }, + 'proc-task-5': { + label: 'Processing Review', + taskType: 'uipath.case-management.agent', + icon: , + }, + // Underwriting stage + 'uw-task-1': { + label: 'Report Ordering', + taskType: 'uipath.case-management.process', + icon: , + }, + 'uw-task-2': { + label: 'Underwriting Verification', + taskType: 'uipath.case-management.rpa', + icon: , + }, + // Closing stage + 'close-task-1': { + label: 'Loan Packet Creation', + taskType: 'uipath.case-management.process', + icon: , + }, + 'close-task-2': { + label: 'Customer Signing', + taskType: 'uipath.case-management.run-human-action', + icon: , + }, + 'close-task-3': { + label: 'Generate Audit Report', + taskType: 'uipath.case-management.process', + icon: , }, - // Processing Stage - { - id: 'processing', - type: 'stage', - position: { x: 448, y: 96 }, - width: 304, - data: { - stageDetails: { - label: 'Processing', - tasks: [ - [{ id: '1', label: 'Liability Check', icon: }], - [{ id: '2', label: 'Credit Review', icon: }], - [ - { id: '3', label: 'Address Verification', icon: }, - { id: '4', label: 'Property Verification', icon: }, - ], - [{ id: '5', label: 'Processing Review', icon: }], - ], - selectedTasks: ['4'], - }, - }, + // Funding stage + 'fund-task-1': { + label: 'Disperse Loan', + taskType: 'uipath.case-management.agent', + icon: , }, - // Underwriting Stage - { - id: 'underwriting', - type: 'stage', - position: { x: 848, y: 96 }, - width: 304, - data: { - stageDetails: { - label: 'Underwriting', - tasks: [ - [{ id: '1', label: 'Report Ordering', icon: }], - [{ id: '2', label: 'Underwriting Verification', icon: }], - ], - }, - }, + 'fund-task-2': { + label: 'Generate Audit Report', + taskType: 'uipath.case-management.process', + icon: , }, - // Closing Stage - { - id: 'closing', - type: 'stage', - position: { x: 1248, y: 96 }, - width: 304, - data: { - stageDetails: { - label: 'Closing', - tasks: [ - [{ id: '1', label: 'Loan Packet Creation', icon: }], - [{ id: '2', label: 'Customer Signing', icon: }], - [{ id: '3', label: 'Generate Audit Report', icon: }], - ], - }, - }, + // Exception stages + 'reject-task-1': { + label: 'Customer Notification', + taskType: 'uipath.case-management.agent', + icon: , }, - // Funding Stage - { - id: 'funding', - type: 'stage', - position: { x: 1648, y: 96 }, - width: 304, - data: { - stageDetails: { - label: 'Funding', - tasks: [ - [{ id: '1', label: 'Disperse Loan', icon: }], - [{ id: '2', label: 'Generate Audit Report', icon: }], - ], - }, - }, + 'reject-task-2': { + label: 'Generate Audit Report', + taskType: 'uipath.case-management.process', + icon: , }, - // Rejected Stage - { - id: 'rejected', - type: 'stage', - position: { x: 1248, y: 400 }, - width: 304, - data: { - stageDetails: { - label: 'Rejected', - isException: true, - tasks: [ - [{ id: '1', label: 'Customer Notification', icon: }], - [{ id: '2', label: 'Generate Audit Report', icon: }], - ], - }, - }, + 'withdraw-task-1': { + label: 'Customer Notification', + taskType: 'uipath.case-management.agent', + icon: , }, - // Withdrawn Stage - { - id: 'withdrawn', - type: 'stage', - position: { x: 448, y: 608 }, - width: 304, - data: { - stageDetails: { - label: 'Withdrawn', - isException: true, - tasks: [ - [{ id: '1', label: 'Customer Notification', icon: }], - [{ id: '2', label: 'Generate Audit Report', icon: }], - ], - }, - }, + 'withdraw-task-2': { + label: 'Generate Audit Report', + taskType: 'uipath.case-management.process', + icon: , }, - ], - edges: [ - // Main flow + }; + + const edges: Edge[] = [ { id: 'e1', type: 'stage', @@ -794,405 +1322,426 @@ export const LoanProcessingWorkflow: Story = { target: 'funding', targetHandle: 'funding____target____left', }, - ] as Edge[], + ]; + + return ( +
+ +
+ ); }, - args: {} as any, // No args needed as we're using parameters }; -const initialTasks: StageTaskItem[][] = [ - [{ id: 'task-1', label: 'KYC Verification', icon: }], - [ - { id: 'task-2', label: 'Document Review', icon: }, - { id: 'task-6', label: 'Credit Check', icon: }, - ], - [ - { id: 'task-3', label: 'Address Check', icon: }, - { id: 'task-4', label: 'Property Check', icon: }, - ], - [{ id: 'task-5', label: 'Final Approval', icon: }], -]; - -const DraggableTaskReorderingStory = () => { - const StageNodeWrapper = useMemo( - () => - function StageNodeWrapperComponent(props: any) { - return ; +/** + * Tasks with context menu (TaskMenu) for grouping operations + * Hover over a task to see the menu icon, click to open the menu + */ +export const WithTaskMenu: Story = { + render: () => { + const tasks: Record = { + 'task-1': { + label: 'Initial Review', + taskType: 'uipath.case-management.process', + icon: , }, - [] - ); - - const nodeTypes = useMemo(() => ({ stage: StageNodeWrapper }), [StageNodeWrapper]); - const edgeTypes = useMemo(() => ({ stage: StageEdge }), []); - - const [nodes, setNodes, onNodesChange] = useNodesState([ - { - id: 'reorder-stage', - type: 'stage', - position: { x: 320, y: 96 }, - data: { - stageDetails: { - label: 'Drag to Reorder Tasks', - tasks: initialTasks, - }, - onTaskClick: (taskId: string) => console.log('Task clicked:', taskId), + 'task-2': { + label: 'Document Verification', + taskType: 'uipath.case-management.rpa', + icon: , }, - }, - ]); - - const [edges, setEdges, onEdgesChange] = useEdgesState([]); - - const handleTaskReorder = useCallback( - (reorderedTasks: StageTaskItem[][]) => { - setNodes((nds) => - nds.map((node) => - node.id === 'reorder-stage' - ? { - ...node, - data: { - ...node.data, - stageDetails: { - ...node.data.stageDetails, - tasks: reorderedTasks, - }, - }, - } - : node - ) - ); - }, - [setNodes] - ); - - const nodesWithHandler = useMemo( - () => - nodes.map((node) => ({ - ...node, - data: { - ...node.data, - onTaskReorder: handleTaskReorder, - }, - })), - [nodes, handleTaskReorder] - ); - - const onConnect = useCallback( - (connection: Connection) => setEdges((eds) => addEdge(connection, eds)), - [setEdges] - ); - - return ( -
- - - - - - - -
- ); + 'task-3': { + label: 'Address Check', + taskType: 'uipath.case-management.rpa', + icon: , + }, + 'task-4': { + label: 'Background Check', + taskType: 'uipath.case-management.agent', + icon: , + }, + 'task-5': { + label: 'Credit Check', + taskType: 'uipath.case-management.rpa', + icon: , + }, + 'task-6': { + label: 'Final Approval', + taskType: 'uipath.case-management.run-human-action', + icon: , + }, + }; + + return ( +
+ +
+ ); + }, }; -export const DraggableTaskReordering: Story = { - name: 'Draggable Task Reordering', - parameters: { - useCustomRender: true, +/** + * Stage with add, replace, and group task functionality. + * - Click the "+" button in the header to add a new task from the toolbox. + * - Hover a task and open the context menu to see "Replace task" option. + * - Clicking "Replace task" opens the toolbox to pick a replacement. + * - Use the "Replace Task" button in the top-right panel to trigger replacement + * from outside the canvas (simulating a properties panel). + */ +const addReplaceInitialTasks: Record = { + 'task-1': { + label: 'Initial Review', + taskType: 'uipath.case-management.process', + icon: , + }, + 'task-2': { + label: 'Document Verification', + taskType: 'uipath.case-management.rpa', + icon: , + }, + 'task-3': { + label: 'Address Check', + taskType: 'uipath.case-management.rpa', + icon: , + }, + 'task-4': { + label: 'Background Check', + taskType: 'uipath.case-management.agent', + icon: , + }, + 'task-5': { + label: 'Final Approval', + taskType: 'uipath.case-management.run-human-action', + icon: , }, - render: () => , - args: {}, }; -const initialTasksForAddReplace: StageTaskItem[][] = [ - [{ id: 'task-1', label: 'Initial Verification', icon: }], - [{ id: 'task-2', label: 'Document Review', icon: }], -]; - -const availableTaskOptions: ListItem[] = [ +const addReplaceTaskOptions: ListItem[] = [ { - id: 'verification-task', - name: 'Verification task', - icon: { Component: () => }, - data: { type: 'verification' }, + id: 'opt-agent', + name: 'Agent Task', + data: { taskType: 'uipath.case-management.agent' }, + icon: { Component: () => }, }, { - id: 'document-task', - name: 'Document task', - icon: { Component: () => }, - data: { type: 'document' }, + id: 'opt-automation', + name: 'RPA Automation', + data: { taskType: 'uipath.case-management.rpa' }, + icon: { Component: () => }, }, { - id: 'process-task', - name: 'Process task', - icon: { Component: () => }, - data: { type: 'process' }, + id: 'opt-api', + name: 'API Automation', + data: { taskType: 'uipath.case-management.api-workflow' }, + icon: { Component: () => }, }, { - id: 'credit-check', - name: 'Credit check', - icon: { Component: () => }, - data: { type: 'credit' }, + id: 'opt-human', + name: 'Human in the Loop', + data: { taskType: 'uipath.case-management.run-human-action' }, + icon: { Component: () => }, }, { - id: 'address-verification', - name: 'Address verification', - icon: { Component: () => }, - data: { type: 'address' }, + id: 'opt-process', + name: 'Agentic Process', + data: { taskType: 'uipath.case-management.process' }, + icon: { Component: () => }, }, ]; const AddAndReplaceTasksStory = () => { - const StageNodeWrapper = useMemo( - () => - function StageNodeWrapperComponent(props: any) { - return ; - }, - [] - ); - - const nodeTypes = useMemo(() => ({ stage: StageNodeWrapper }), [StageNodeWrapper]); - const edgeTypes = useMemo(() => ({ stage: StageEdge }), []); - + const stageId = 'stage-1'; + + const [stageTaskIds, setStageTaskIds] = useState>({ + [stageId]: [['task-1'], ['task-2'], ['task-3', 'task-4'], ['task-5']], + }); + const [allTasks, setAllTasks] = useState(addReplaceInitialTasks); + const [selectedTaskId, setSelectedTaskId] = useState(null); + const [actionLog, setActionLog] = useState(''); const [pendingReplaceTask, setPendingReplaceTask] = useState< { groupIndex: number; taskIndex: number } | undefined >(); - const [selectedTaskId, setSelectedTaskId] = useState(); const [menuAnchorEl, setMenuAnchorEl] = useState(null); - const [nodesState, setNodes, onNodesChange] = useNodesState([ - { - id: 'add-replace-stage', - type: 'stage', - position: { x: 320, y: 96 }, - data: { - stageDetails: { - label: 'Add, Replace, and Group Tasks', - tasks: initialTasksForAddReplace, + // Handle add task from toolbox + const handleAddTaskFromToolbox = useCallback( + (taskItem: ListItem) => { + const newTaskId = `added-${Date.now()}`; + const IconComp = taskItem.icon?.Component; + + setAllTasks((prev) => ({ + ...prev, + [newTaskId]: { + label: taskItem.name, + taskType: taskItem.data?.taskType || 'uipath.case-management.process', + icon: IconComp ? ( + + ) : ( + + ), }, - taskOptions: availableTaskOptions, - }, - }, - ]); - const [edges, setEdges, onEdgesChange] = useEdgesState([]); + })); - const handleAddTask = useCallback( - (taskItem: ListItem) => { - const newTask: StageTaskItem = { - id: `${taskItem.id}-${Date.now()}`, - label: taskItem.name, - icon: taskItem.icon?.Component ? : undefined, - }; + setStageTaskIds((prev) => ({ + ...prev, + [stageId]: [...(prev[stageId] || []), [newTaskId]], + })); - setNodes((prevNodes) => - prevNodes.map((node) => - node.id === 'add-replace-stage' - ? { - ...node, - data: { - ...node.data, - stageDetails: { - ...node.data.stageDetails, - tasks: [...node.data.stageDetails.tasks, [newTask]], - }, - }, - } - : node - ) - ); + setActionLog(`Added task: ${taskItem.name}`); }, - [setNodes] + [stageId] ); + // Handle replace task from toolbox const handleReplaceTask = useCallback( - (taskItem: StageTaskItem, groupIndex: number, taskIndex: number) => { - // Validate indices - if (groupIndex < 0 || taskIndex < 0) { - return; - } - - // The component passes a ListItem cast as StageTaskItem, so convert it properly - const listItem = taskItem as unknown as ListItem; - const replacedTask: StageTaskItem = { - id: `${listItem.id}-replaced-${Date.now()}`, - label: listItem.name, - icon: listItem.icon?.Component ? : undefined, - }; - - setNodes((prevNodes) => - prevNodes.map((node) => { - if (node.id !== 'add-replace-stage') { - return node; - } - - const prevTasks = node.data.stageDetails.tasks; - - // Validate that indices are within bounds - if (groupIndex >= prevTasks.length) { - return node; - } - - const currentGroup = prevTasks[groupIndex]; - if (!currentGroup || taskIndex >= currentGroup.length) { - return node; - } + (newTask: ListItem, groupIndex: number, taskIndex: number) => { + const newTaskId = `replaced-${Date.now()}`; + const IconComp = newTask.icon?.Component; + + setAllTasks((prev) => ({ + ...prev, + [newTaskId]: { + label: newTask.name, + taskType: newTask.data?.taskType || 'uipath.case-management.process', + icon: IconComp ? ( + + ) : ( + + ), + }, + })); - const updatedTasks = prevTasks.map((group: StageTaskItem[], gIdx: number) => { - if (gIdx === groupIndex) { - return group.map((task: StageTaskItem, tIdx: number) => - tIdx === taskIndex ? replacedTask : task - ); - } - return group; - }); - - return { - ...node, - data: { - ...node.data, - stageDetails: { - ...node.data.stageDetails, - tasks: updatedTasks, - }, - }, - }; - }) - ); + setStageTaskIds((prev) => { + const currentTaskIds = [...(prev[stageId] || [])].map((group) => [...group]); + const targetGroup = currentTaskIds[groupIndex]; + if (targetGroup && targetGroup[taskIndex] !== undefined) { + targetGroup[taskIndex] = newTaskId; + } + return { ...prev, [stageId]: currentTaskIds }; + }); setPendingReplaceTask(undefined); + setActionLog( + `Replaced task at group ${groupIndex}, task ${taskIndex} with "${newTask.name}"` + ); }, - [setNodes] + [stageId] ); - const groupModificationHandlers = useMemo( - () => createGroupModificationHandlers(), - [] - ); - - const handleTaskGroupModification = useCallback( - (groupModificationType: GroupModificationType, groupIndex: number, taskIndex: number) => { - const handler = getHandlerForModificationType( - groupModificationHandlers, - groupModificationType - ); - - setNodes((prevNodes) => - prevNodes.map((node) => { - if (node.id !== 'add-replace-stage') { - return node; - } - - const prevTasks = node.data.stageDetails.tasks; - const updatedTasks = handler(prevTasks, groupIndex, taskIndex); - - return { - ...node, - data: { - ...node.data, - stageDetails: { - ...node.data.stageDetails, - tasks: updatedTasks, - }, - }, - }; - }) - ); + // Handle group modification from context menu + const handleGroupModification = useCallback( + (type: GroupModificationType, groupIndex: number, taskIndex: number) => { + setActionLog(`Group modification: ${type} at group ${groupIndex}, task ${taskIndex}`); + + setStageTaskIds((prev) => { + const handler = getHandlerForModificationType( + createGroupModificationHandlers(), + type + ); + const currentTaskIds = (prev[stageId] || []).map((group) => [...group]); + const updatedTaskIds = handler(currentTaskIds, groupIndex, taskIndex); + return { ...prev, [stageId]: updatedTaskIds }; + }); }, - [groupModificationHandlers, setNodes] + [stageId] ); - // Handle task click in canvas (for selection) - const handleTaskClick = useCallback((taskId: string) => { - setSelectedTaskId(taskId); - }, []); - - // Get current tasks from node state for menu items - const currentTasks = - nodesState.find((n) => n.id === 'add-replace-stage')?.data.stageDetails.tasks || []; - - // Create menu items for task selection + // Create menu items for task selection (simulates properties panel) + const currentTaskIds = stageTaskIds[stageId] || []; const taskMenuItems = useMemo(() => { - return currentTasks.flatMap((group, groupIndex) => - group.map((task, taskIndex) => ({ - title: task.label, - variant: 'item' as const, - startIcon: task.icon, - onClick: () => { - setSelectedTaskId(task.id); - setPendingReplaceTask({ groupIndex, taskIndex }); - setMenuAnchorEl(null); - }, - })) + return currentTaskIds.flatMap((group, groupIndex) => + group.map((taskId, taskIndex) => { + const taskInfo = allTasks[taskId]; + return { + title: taskInfo?.label || taskId, + variant: 'item' as const, + startIcon: taskInfo?.icon, + onClick: () => { + setSelectedTaskId(taskId); + setPendingReplaceTask({ groupIndex, taskIndex }); + setMenuAnchorEl(null); + }, + }; + }) ); - }, [currentTasks]); + }, [currentTaskIds, allTasks]); - // Update node data with pendingReplaceTask and selectedTaskId - const nodesWithMetadata = useMemo( - () => - nodesState.map((node) => - node.id === 'add-replace-stage' - ? { - ...node, - data: { - ...node.data, - pendingReplaceTask: pendingReplaceTask, - stageDetails: { - ...node.data.stageDetails, - selectedTasks: selectedTaskId ? [selectedTaskId] : undefined, - }, - onAddTaskFromToolbox: handleAddTask, - onReplaceTaskFromToolbox: handleReplaceTask, - onTaskGroupModification: handleTaskGroupModification, - onTaskClick: handleTaskClick, - }, - } - : node - ), + const replaceButtonLabel = useMemo(() => { + if (pendingReplaceTask) { + const taskId = currentTaskIds[pendingReplaceTask.groupIndex]?.[pendingReplaceTask.taskIndex]; + const taskInfo = taskId ? allTasks[taskId] : undefined; + if (taskInfo) { + return `Replacing: ${taskInfo.label}`; + } + } + return 'Replace Task'; + }, [pendingReplaceTask, currentTaskIds, allTasks]); + + // Create stage nodes + const stageNodes: Node[] = useMemo( + () => [ + { + id: stageId, + type: 'stage', + position: { x: 48, y: 96 }, + style: { width: 304 }, + data: { + nodeType: 'case-management:Stage', + stageDetails: { + label: 'Add, Replace & Group Tasks', + taskIds: stageTaskIds[stageId] || [], + selectedTasks: selectedTaskId ? [selectedTaskId] : undefined, + }, + pendingReplaceTask, + taskOptions: addReplaceTaskOptions, + onAddTaskFromToolbox: handleAddTaskFromToolbox, + onReplaceTaskFromToolbox: handleReplaceTask, + onTaskClick: (taskId: string) => { + setSelectedTaskId(taskId); + setActionLog(`Selected: ${taskId}`); + }, + } as Partial, + }, + ], [ - nodesState, - pendingReplaceTask, + stageTaskIds, selectedTaskId, - handleAddTask, + pendingReplaceTask, + handleAddTaskFromToolbox, handleReplaceTask, - handleTaskGroupModification, - handleTaskClick, ] ); + // Create task nodes + const taskNodes = useMemo(() => { + return createTaskNodes({ + stageId, + taskIds: stageTaskIds[stageId] || [], + tasks: allTasks, + stageWidth: 304, + onTaskClick: (taskId) => { + setSelectedTaskId(taskId); + setActionLog(`Selected: ${taskId}`); + }, + onTaskSelect: setSelectedTaskId, + enableCrossStage: false, + onGroupModification: handleGroupModification, + onRequestReplaceTask: (groupIndex, taskIndex) => { + setPendingReplaceTask({ groupIndex, taskIndex }); + }, + }); + }, [stageTaskIds, allTasks, handleGroupModification]); + + const allNodes = useMemo(() => [...stageNodes, ...taskNodes], [stageNodes, taskNodes]); + const allNodesRef = useRef(allNodes); + allNodesRef.current = allNodes; + + const [nodes, setNodes] = useState(allNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + + useEffect(() => { + setNodes((prev) => mergeNodes(prev, allNodes)); + }, [allNodes]); + + // Custom onNodesChange that applies RF changes then re-merges our computed data + const onNodesChange = useCallback((changes: NodeChange[]) => { + setNodes((nds) => { + const updated = applyNodeChanges(changes, nds); + return mergeNodes(updated, allNodesRef.current); + }); + }, []); + const onConnect = useCallback( - (connection: Connection) => setEdges((eds) => addEdge(connection, eds)), + (connection: Connection) => setEdges((eds) => addEdge({ ...connection, type: 'stage' }, eds)), [setEdges] ); - // Compute button label based on whether a task is being replaced - const replaceButtonLabel = useMemo(() => { - if (pendingReplaceTask) { - const taskBeingReplaced = - currentTasks[pendingReplaceTask.groupIndex]?.[pendingReplaceTask.taskIndex]; - if (taskBeingReplaced) { - return `Replacing Task: ${taskBeingReplaced.label}`; - } - } - return 'Replace Task'; - }, [pendingReplaceTask, currentTasks]); + const nodeTypes = useMemo( + () => ({ + stage: StageNodeWrapper, + task: TaskNodeWrapper, + placeholder: (props: any) => , + }), + [] + ); + const edgeTypes = useMemo(() => ({ stage: StageEdge }), []); return (
{ { - setMenuAnchorEl(e.currentTarget as HTMLElement); - }} + onClick={(e) => setMenuAnchorEl(e.currentTarget as HTMLElement)} /> { + {actionLog && ( + +
+ {actionLog} +
+
+ )}
@@ -1233,108 +1794,147 @@ const AddAndReplaceTasksStory = () => { export const AddAndReplaceTasks: Story = { name: 'Add, Replace, and Group Tasks', - parameters: { - useCustomRender: true, - }, render: () => , - args: {}, }; -const AddTaskLoadingStory = () => { - const StageNodeWrapper = useMemo( - () => - function StageNodeWrapperComponent(props: any) { - return ; - }, - [] - ); +// Module-level constants for AddTaskLoadingStory to prevent re-renders +const loadingStoryStages = [ + { id: 'empty-stage', label: 'Empty Stage (click +)', position: { x: 48, y: 96 }, width: 304 }, + { id: 'tasks-stage', label: 'With Tasks (click +)', position: { x: 400, y: 96 }, width: 304 }, +] as const; - const nodeTypes = useMemo(() => ({ stage: StageNodeWrapper }), [StageNodeWrapper]); - const edgeTypes = useMemo(() => ({ stage: StageEdge }), []); +const loadingStoryInitialTaskIds: Record = { + 'empty-stage': [], + 'tasks-stage': [['task-1'], ['task-2']], +}; - const initialNodes = useMemo( - () => [ - { - id: 'loading-stage-empty', - type: 'stage', - position: { x: 48, y: 96 }, - width: 304, - data: { - stageDetails: { - label: 'Empty Stage (click +)', - tasks: [], - }, - addTaskLoading: false, - }, - }, - { - id: 'loading-stage-with-tasks', - type: 'stage', - position: { x: 400, y: 96 }, - width: 304, - data: { - stageDetails: { - label: 'With Tasks (click +)', - tasks: [ - [{ id: 'task-1', label: 'Existing Task', icon: }], - [{ id: 'task-2', label: 'Another Task', icon: }], - ], - }, - addTaskLoading: false, - }, - }, - ], - [] - ); +const loadingStoryInitialTasks: Record = { + 'task-1': { + label: 'Existing Task', + taskType: 'uipath.case-management.rpa', + icon: , + }, + 'task-2': { + label: 'Another Task', + taskType: 'uipath.case-management.process', + icon: , + }, +}; - const [nodesState, setNodes, onNodesChange] = useNodesState(initialNodes); - const [edges, setEdges, onEdgesChange] = useEdgesState([]); +/** + * Demonstrates the add task loading state. + * - Click the "+" button to simulate adding a task with a 3-second API delay. + * - The button shows a spinner and is disabled while loading. + * - The empty stage "Add first task" link is also disabled while loading. + */ +const AddTaskLoadingStory = () => { + const [loadingStages, setLoadingStages] = useState>({}); + const [stageTaskIds, setStageTaskIds] = useState>(loadingStoryInitialTaskIds); + const [allTasks, setAllTasks] = useState>(loadingStoryInitialTasks); + + const handleTaskAdd = useCallback((stageIdToLoad: string) => { + setLoadingStages((prev) => ({ ...prev, [stageIdToLoad]: true })); + + // Simulate 3-second API delay, then add a new task + setTimeout(() => { + const newTaskId = `new-task-${Date.now()}`; + setAllTasks((prev) => ({ + ...prev, + [newTaskId]: { + label: 'New Task', + taskType: 'uipath.case-management.agent', + icon: , + }, + })); - const setNodeLoading = useCallback( - (nodeId: string, loading: boolean) => { - setNodes((nds) => - nds.map((node) => - node.id === nodeId ? { ...node, data: { ...node.data, addTaskLoading: loading } } : node - ) - ); - }, - [setNodes] - ); + setStageTaskIds((prev) => ({ + ...prev, + [stageIdToLoad]: [...(prev[stageIdToLoad] || []), [newTaskId]], + })); - const handleTaskAddForNode = useCallback( - (nodeId: string) => { - setNodeLoading(nodeId, true); - // Simulate API delay of 3 seconds - setTimeout(() => { - setNodeLoading(nodeId, false); - }, 3000); - }, - [setNodeLoading] - ); + setLoadingStages((prev) => ({ ...prev, [stageIdToLoad]: false })); + }, 3000); + }, []); - // Inject a per-node onTaskAdd handler - const nodesWithHandler = useMemo( + // Create stage nodes with addTaskLoading + const stageNodes: Node[] = useMemo( () => - nodesState.map((node) => ({ - ...node, + loadingStoryStages.map((stage) => ({ + id: stage.id, + type: 'stage', + position: stage.position, + style: { width: stage.width }, data: { - ...node.data, - onTaskAdd: () => handleTaskAddForNode(node.id), - }, + nodeType: 'case-management:Stage', + stageDetails: { + label: stage.label, + taskIds: stageTaskIds[stage.id] || [], + }, + addTaskLoading: loadingStages[stage.id] || false, + onTaskAdd: () => handleTaskAdd(stage.id), + } as Partial, })), - [nodesState, handleTaskAddForNode] + [stageTaskIds, loadingStages, handleTaskAdd] ); + // Create task nodes for all stages + const taskNodes = useMemo(() => { + const allTaskNodes: Node[] = []; + for (const stage of loadingStoryStages) { + const ids = stageTaskIds[stage.id] || []; + const nodes = createTaskNodes({ + stageId: stage.id, + taskIds: ids, + tasks: allTasks, + stageWidth: stage.width, + enableCrossStage: false, + onRequestReplaceTask: () => {}, + }); + allTaskNodes.push(...nodes); + } + return allTaskNodes; + }, [stageTaskIds, allTasks]); + + const allNodes = useMemo(() => [...stageNodes, ...taskNodes], [stageNodes, taskNodes]); + const allNodesRef = useRef(allNodes); + allNodesRef.current = allNodes; + + const [nodes, setNodes] = useState(allNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + + // Sync computed data into nodes, preserving RF internal state (selected, etc.) + useEffect(() => { + setNodes((prev) => mergeNodes(prev, allNodes)); + }, [allNodes]); + + // Custom onNodesChange that applies RF changes then re-merges our computed data + const onNodesChange = useCallback((changes: NodeChange[]) => { + setNodes((nds) => { + const updated = applyNodeChanges(changes, nds); + return mergeNodes(updated, allNodesRef.current); + }); + }, []); + const onConnect = useCallback( - (connection: Connection) => setEdges((eds) => addEdge(connection, eds)), + (connection: Connection) => setEdges((eds) => addEdge({ ...connection, type: 'stage' }, eds)), [setEdges] ); + const nodeTypes = useMemo( + () => ({ + stage: StageNodeWrapper, + task: TaskNodeWrapper, + placeholder: (props: any) => , + }), + [] + ); + const edgeTypes = useMemo(() => ({ stage: StageEdge }), []); + return (
{ export const AddTaskLoading: Story = { name: 'Add Task Loading State', - parameters: { - useCustomRender: true, - }, render: () => , - args: {}, }; diff --git a/packages/apollo-react/src/canvas/components/StageNode/StageNode.styles.ts b/packages/apollo-react/src/canvas/components/StageNode/StageNode.styles.ts index 4ba037ec7..4cd21b88e 100644 --- a/packages/apollo-react/src/canvas/components/StageNode/StageNode.styles.ts +++ b/packages/apollo-react/src/canvas/components/StageNode/StageNode.styles.ts @@ -138,7 +138,7 @@ export const StageTaskGroup = styled.div<{ isParallel?: boolean }>` export const StageParallelLabel = styled.div` position: absolute; - left: -52px; + left: -48px; top: 50%; padding: 0px ${Padding.PadM}; display: flex; diff --git a/packages/apollo-react/src/canvas/components/StageNode/StageNode.tsx b/packages/apollo-react/src/canvas/components/StageNode/StageNode.tsx index ba57ead8c..0280568f0 100644 --- a/packages/apollo-react/src/canvas/components/StageNode/StageNode.tsx +++ b/packages/apollo-react/src/canvas/components/StageNode/StageNode.tsx @@ -1,24 +1,20 @@ -import { - closestCenter, - DndContext, - type DragEndEvent, - type DragMoveEvent, - type DragOverEvent, - DragOverlay, - type DragStartEvent, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, -} from '@dnd-kit/core'; -import { - SortableContext, - sortableKeyboardCoordinates, - verticalListSortingStrategy, -} from '@dnd-kit/sortable'; +/** + * StageNode - Stage node that uses React Flow TaskNodes as children + * + * This component works with the TaskNode component where tasks + * are rendered as separate React Flow nodes with parentId pointing to the stage. + * Task positions are calculated based on order, not user drag position. + * + * Key features: + * - Uses taskIds: string[][] to reference tasks + * - Does not render tasks directly - they're separate React Flow nodes + * - Provides TaskNodeProvider context for child TaskNodes + * - Renders parallel brackets/labels based on taskIds grouping + */ + import { FontVariantToken, Padding, Spacing } from '@uipath/apollo-core'; import { Column, Row } from '@uipath/apollo-react/canvas/layouts'; -import { Position, useStore, useViewport } from '@uipath/apollo-react/canvas/xyflow/react'; +import { Position, useStore } from '@uipath/apollo-react/canvas/xyflow/react'; import { ApCircularProgress, ApIcon, @@ -28,16 +24,20 @@ import { ApTypography, } from '@uipath/apollo-react/material'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { createPortal } from 'react-dom'; +import { useCrossStageDragState } from '../../hooks/CrossStageDragContext'; import type { HandleGroupManifest } from '../../schema/node-definition'; import { useConnectedHandles } from '../BaseCanvas/ConnectedHandlesContext'; import { useButtonHandles } from '../ButtonHandle/useButtonHandles'; import { ExecutionStatusIcon } from '../ExecutionStatusIcon'; import { FloatingCanvasPanel } from '../FloatingCanvasPanel'; -import { NodeContextMenu, type NodeMenuItem } from '../NodeContextMenu'; +import { NodeContextMenu } from '../NodeContextMenu'; import { useNodeSelection } from '../NodePropertiesPanel/hooks'; +import { TaskNodeProvider } from '../TaskNode/TaskNodeContext'; +import { + calculateStageContentHeight, + DEFAULT_TASK_POSITION_CONFIG, +} from '../TaskNode/useTaskPositions'; import { type ListItem, Toolbox } from '../Toolbox'; -import { DraggableTask, TaskContent } from './DraggableTask'; import { INDENTATION_WIDTH, STAGE_CONTENT_INSET, @@ -46,20 +46,84 @@ import { StageHeader, StageParallelBracket, StageParallelLabel, - StageTask, - StageTaskGroup, StageTaskList, StageTitleContainer, StageTitleInput, } from './StageNode.styles'; import type { StageNodeProps } from './StageNode.types'; -import { flattenTasks, getProjection, reorderTasks } from './StageNode.utils'; -import { getContextMenuItems, getDivider, getMenuItem } from './StageNodeTaskUtilities'; -interface TaskStateReference { - isParallel: boolean; - groupIndex: number; - taskIndex: number; +/** + * Calculate extra height needed for a task based on its execution content + */ +function calculateExecutionExtraHeight(execution?: Record): number { + if (!execution) return 0; + if (execution.duration || execution.retryDuration || execution.badge) { + if (execution.retryDuration || execution.badge) { + return 26; // Taller for retry/badge content (62px total) + } else { + return 18; // Normal for just duration (54px total) + } + } + return 2; // Minimal for status icon only (38px total) +} + +/** + * Calculate the Y position for the first task in a parallel group + * (The bracket starts at the first task, not the label) + */ +function calculateParallelGroupY( + groupIndex: number, + taskIds: string[][], + hasStageExecution: boolean, + taskExecutionData: Record }>, + config = DEFAULT_TASK_POSITION_CONFIG +): number { + // Add extra header height if stage has execution with duration + const headerHeight = hasStageExecution + ? config.headerHeight + config.headerExecutionDescriptionHeight + : config.headerHeight; + + let y = headerHeight + config.contentPaddingTop; + + for (let i = 0; i < groupIndex; i++) { + const group = taskIds[i]; + if (!group) continue; + + // Calculate height for each task in the group based on execution data + for (let j = 0; j < group.length; j++) { + const taskId = group[j]; + const execution = taskId ? taskExecutionData[taskId]?.execution : undefined; + const taskHeight = config.taskHeight + calculateExecutionExtraHeight(execution); + y += taskHeight; + if (j < group.length - 1) { + y += config.taskGap; + } + } + y += config.taskGap; + } + + return y; +} + +/** + * Calculate the height of a parallel group bracket based on task execution data + */ +function calculateParallelGroupHeight( + group: string[], + taskExecutionData: Record }>, + config = DEFAULT_TASK_POSITION_CONFIG +): number { + let height = 0; + for (let i = 0; i < group.length; i++) { + const taskId = group[i]; + const execution = taskId ? taskExecutionData[taskId]?.execution : undefined; + const taskHeight = config.taskHeight + calculateExecutionExtraHeight(execution); + height += taskHeight; + if (i < group.length - 1) { + height += config.taskGap; + } + } + return height; } const StageNodeComponent = (props: StageNodeProps) => { @@ -67,6 +131,7 @@ const StageNodeComponent = (props: StageNodeProps) => { dragging, selected, id, + nodeType = 'stage', width, execution, stageDetails, @@ -80,93 +145,45 @@ const StageNodeComponent = (props: StageNodeProps) => { onTaskAdd, onAddTaskFromToolbox, onTaskToolboxSearch, + onReplaceTaskFromToolbox, + replaceTaskTarget, + onReplaceTaskTargetChange, onTaskClick, - onTaskGroupModification, + onTaskSelect, onStageTitleChange, - onTaskReorder, - onReplaceTaskFromToolbox, + // onTaskIdsChange - will be used in Phase 3 for cross-stage drag } = props; - const taskWidth = width ? width - STAGE_CONTENT_INSET : undefined; - - const tasks = useMemo(() => stageDetails?.tasks || [], [stageDetails?.tasks]); - const flatTasks = useMemo(() => tasks.flat(), [tasks]); - const taskIds = useMemo(() => flatTasks.map((task) => task.id), [flatTasks]); - + const taskIds = useMemo(() => stageDetails?.taskIds || [], [stageDetails?.taskIds]); const isException = stageDetails?.isException; const isReadOnly = !!stageDetails?.isReadOnly; const icon = stageDetails?.icon; - const selectedTasks = stageDetails?.selectedTasks; const defaultContent = stageDetails?.defaultContent || 'Add first task'; const status = execution?.stageStatus?.status; const statusLabel = execution?.stageStatus?.label; const stageDuration = execution?.stageStatus?.duration; - const reGroupTaskFunction = useMemo( - () => onTaskGroupModification || (() => {}), - [onTaskGroupModification] - ); const isStageTitleEditable = !!onStageTitleChange && !isReadOnly; const [isHovered, setIsHovered] = useState(false); const [label, setLabel] = useState(props.stageDetails.label); + const [toolboxMode, setToolboxMode] = useState<'add' | 'replace' | null>(null); + const replaceTaskRef = useRef<{ groupIndex: number; taskIndex: number } | null>(null); + const [isStageTitleEditing, setIsStageTitleEditing] = useState(false); + const stageTitleRef = useRef(null); useEffect(() => { setLabel(props.stageDetails.label); }, [props.stageDetails.label]); - const [isStageTitleEditing, setIsStageTitleEditing] = useState(false); - const stageTitleRef = useRef(null); - const taskStateReference = useRef({ - isParallel: false, - groupIndex: -1, - taskIndex: -1, - }); const isConnecting = useStore((state) => !!state.connectionClickStartHandle); const connectedHandleIds = useConnectedHandles(id); - const [isAddingTask, setIsAddingTask] = useState(false); - const [isReplacingTask, setIsReplacingTask] = useState(false); - - useEffect(() => { - if (pendingReplaceTask?.groupIndex != null && pendingReplaceTask?.taskIndex != null) { - const taskGroup = tasks[pendingReplaceTask.groupIndex]; - taskStateReference.current = { - isParallel: (taskGroup?.length ?? 0) > 1, - groupIndex: pendingReplaceTask.groupIndex, - taskIndex: pendingReplaceTask.taskIndex, - }; - setIsReplacingTask(true); - } else setIsReplacingTask(false); - }, [pendingReplaceTask, tasks]); - - const [activeDragId, setActiveDragId] = useState(null); - const [offsetLeft, setOffsetLeft] = useState(0); - const [overId, setOverId] = useState(null); - const activeTask = useMemo( - () => flatTasks.find((t) => t.id === activeDragId), - [flatTasks, activeDragId] - ); - const isActiveTaskParallel = useMemo(() => { - if (!activeDragId) { - return false; - } - const group = tasks.find((g) => g.some((t) => t.id === activeDragId)); - return group ? group.length > 1 : false; - }, [tasks, activeDragId]); - - const { zoom } = useViewport(); - - const projected = useMemo(() => { - if (!activeDragId || !overId) return null; - return getProjection(tasks, activeDragId, overId, offsetLeft); - }, [tasks, activeDragId, overId, offsetLeft]); - useEffect(() => { if (selected === false) { - setIsAddingTask(false); - setIsReplacingTask(false); + setToolboxMode(null); + replaceTaskRef.current = null; } }, [selected]); @@ -188,19 +205,6 @@ const StageNodeComponent = (props: StageNodeProps) => { setLabel((e.target as HTMLInputElement).value); }, []); - const handleStageTitleClickToSave = useCallback( - (e: React.FocusEvent | MouseEvent) => { - if (isStageTitleEditing && !stageTitleRef.current?.contains(e.target as Node)) { - setIsStageTitleEditing(false); - if (onStageTitleChange) { - if (label.trim() === '') setLabel('Untitled Stage'); - onStageTitleChange(label); - } - } - }, - [isStageTitleEditing, onStageTitleChange, label] - ); - const handleStageTitleBlurToSave = useCallback(() => { if (isStageTitleEditing) { setIsStageTitleEditing(false); @@ -223,106 +227,79 @@ const StageNodeComponent = (props: StageNodeProps) => { [onStageTitleChange, label] ); - useEffect(() => { - if (isStageTitleEditing) { - document.addEventListener('click', handleStageTitleClickToSave); - } - return () => { - document.removeEventListener('click', handleStageTitleClickToSave); - }; - }, [handleStageTitleClickToSave, isStageTitleEditing]); - - const contextMenuItems = useCallback( - ( - isParallel: boolean, - groupIndex: number, - taskIndex: number, - tasksLength: number, - taskGroupLength: number, - isAboveParallel: boolean, - isBelowParallel: boolean - ) => { - const items: NodeMenuItem[] = []; - - if (onReplaceTaskFromToolbox) { - items.push(getMenuItem('replace-task', 'Replace task', () => setIsReplacingTask(true))); - items.push(getDivider()); - } - - if (onTaskGroupModification) { - const reGroupOptions = getContextMenuItems( - isParallel, - groupIndex, - tasksLength, - taskIndex, - taskGroupLength, - isAboveParallel, - isBelowParallel, - reGroupTaskFunction - ); - return [...items, ...reGroupOptions]; - } - - return items; - }, - [onReplaceTaskFromToolbox, onTaskGroupModification, reGroupTaskFunction] - ); - const { setSelectedNodeId } = useNodeSelection(); const handleStageClick = useCallback(() => { onStageClick?.(); }, [onStageClick]); - const handleTaskClick = useCallback( - (e: React.MouseEvent, taskElementId: string) => { - e.stopPropagation(); - onTaskClick?.(taskElementId); - setSelectedNodeId(id); - }, - [onTaskClick, setSelectedNodeId, id] - ); - const handleTaskAddClick = useCallback( (event: React.MouseEvent) => { event.stopPropagation(); + replaceTaskRef.current = null; if (onTaskAdd) { onTaskAdd(); } else if (onAddTaskFromToolbox) { - setIsAddingTask(true); + setToolboxMode('add'); } setSelectedNodeId(id); }, [onTaskAdd, onAddTaskFromToolbox, setSelectedNodeId, id] ); - const handleAddTaskToolboxItemSelected = useCallback( + // Watch for external replace task trigger + useEffect(() => { + if (replaceTaskTarget) { + replaceTaskRef.current = replaceTaskTarget; + setToolboxMode('replace'); + } + }, [replaceTaskTarget]); + + // Watch for pending replace task from properties panel + useEffect(() => { + if (pendingReplaceTask?.groupIndex != null && pendingReplaceTask?.taskIndex != null) { + replaceTaskRef.current = { + groupIndex: pendingReplaceTask.groupIndex, + taskIndex: pendingReplaceTask.taskIndex, + }; + setToolboxMode('replace'); + } + }, [pendingReplaceTask]); + + const handleToolboxItemSelected = useCallback( (item: ListItem) => { - onAddTaskFromToolbox?.(item); - setIsAddingTask(false); + if (toolboxMode === 'replace' && replaceTaskRef.current && onReplaceTaskFromToolbox) { + onReplaceTaskFromToolbox(item, replaceTaskRef.current.groupIndex, replaceTaskRef.current.taskIndex); + } else if (toolboxMode === 'add') { + onAddTaskFromToolbox?.(item); + } + setToolboxMode(null); + replaceTaskRef.current = null; + onReplaceTaskTargetChange?.(null); setSelectedNodeId(id); }, - [onAddTaskFromToolbox, setSelectedNodeId, id] + [toolboxMode, onReplaceTaskFromToolbox, onAddTaskFromToolbox, onReplaceTaskTargetChange, setSelectedNodeId, id] ); - const handleReplaceTaskToolboxItemSelected = useCallback( - (item: ListItem) => { - setIsReplacingTask(true); - const groupIndex = taskStateReference.current.groupIndex; - const taskIndex = taskStateReference.current.taskIndex; - const taskId = tasks[groupIndex]?.[taskIndex]?.id; - if (taskId) { - onTaskClick?.(taskId); - } - onReplaceTaskFromToolbox?.( - item, - taskStateReference.current.groupIndex, - taskStateReference.current.taskIndex - ); - setIsReplacingTask(false); - }, - [onReplaceTaskFromToolbox, onTaskClick, tasks] + const handleToolboxClose = useCallback(() => { + setToolboxMode(null); + replaceTaskRef.current = null; + onReplaceTaskTargetChange?.(null); + }, [onReplaceTaskTargetChange]); + + // Calculate task width and create CSS variables for child TaskNodes + const taskWidth = width ? width - STAGE_CONTENT_INSET : undefined; + const taskWidthStyle = useMemo( + () => + taskWidth + ? ({ + '--stage-task-width': `${taskWidth}px`, + '--stage-task-width-parallel': `${taskWidth - INDENTATION_WIDTH}px`, + } as React.CSSProperties) + : undefined, + [taskWidth] ); + // Handle configuration for connection handles const handleConfigurations: HandleGroupManifest[] = useMemo( () => isException @@ -383,330 +360,322 @@ const StageNodeComponent = (props: StageNodeProps) => { ], [isException, id, selected, isHovered, isConnecting] ); + const handleElements = useButtonHandles({ handleConfigurations, shouldShowHandles, nodeId: id, selected, }); - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { - distance: 3, - }, - }), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }) - ); - const resetState = useCallback(() => { - setActiveDragId(null); - setOffsetLeft(0); - setOverId(null); - }, []); - - const handleDragStart = useCallback((event: DragStartEvent) => { - setActiveDragId(event.active.id as string); - }, []); - - const handleDragMove = useCallback( - (event: DragMoveEvent) => { - setOffsetLeft(event.delta.x / zoom); - }, - [zoom] + // TaskNodeProvider value + const taskNodeContextValue = useMemo( + () => ({ + stageId: id, + stageNodeType: nodeType, + taskIds, + isReadOnly, + onTaskClick, + onTaskSelect, + }), + [id, nodeType, taskIds, isReadOnly, onTaskClick, onTaskSelect] ); - const handleDragOver = useCallback((event: DragOverEvent) => { - setOverId((event.over?.id as string) ?? null); - }, []); + // Get cross-stage drag state for bracket expansion calculation + const dragState = useCrossStageDragState(); + + // Calculate display taskIds with placeholder for bracket height calculation + const displayTaskIds = useMemo(() => { + if (dragState?.isDragging && dragState.taskId) { + // Case 1: This stage is the TARGET stage - add placeholder and remove dragged task + if (dragState.targetStageId === id && dragState.dropPosition) { + const { groupIndex, taskIndex, isParallel } = dragState.dropPosition; + const tempTaskIds = taskIds.map((g) => [...g]); + + // Remove dragged task if in this stage + for (let gi = 0; gi < tempTaskIds.length; gi++) { + const group = tempTaskIds[gi]; + if (!group) continue; + const idx = group.indexOf(dragState.taskId); + if (idx !== -1) { + group.splice(idx, 1); + } + } - const handleDragEnd = useCallback( - (event: DragEndEvent) => { - const { active, over } = event; - const currentOffsetLeft = offsetLeft; - resetState(); + const filtered = tempTaskIds.filter((g) => g && g.length > 0); - if (!over || !onTaskReorder) { - return; - } + // Insert placeholder + if (groupIndex >= filtered.length) { + filtered.push(['__placeholder__']); + } else if (isParallel && filtered[groupIndex]) { + filtered[groupIndex]!.splice(taskIndex, 0, '__placeholder__'); + } else { + filtered.splice(groupIndex, 0, ['__placeholder__']); + } - const projection = getProjection( - tasks, - active.id as string, - over.id as string, - currentOffsetLeft - ); - if (!projection) { - return; + return filtered; } - // For in-place movement, skip if depth hasn't changed - if (active.id === over.id) { - const flattened = flattenTasks(tasks); - const activeTask = flattened.find((t) => t.id === active.id); - if (activeTask && activeTask.depth === projection.depth) { - return; + // Case 2: This stage is the SOURCE stage and target is a different stage + // Remove the dragged task so the stage height shrinks + if ( + dragState.sourceStageId === id && + dragState.targetStageId !== id + ) { + const tempTaskIds = taskIds.map((g) => [...g]); + + // Remove dragged task from this stage + for (let gi = 0; gi < tempTaskIds.length; gi++) { + const group = tempTaskIds[gi]; + if (!group) continue; + const idx = group.indexOf(dragState.taskId); + if (idx !== -1) { + group.splice(idx, 1); + } } + + return tempTaskIds.filter((g) => g && g.length > 0); } + } + return taskIds; + }, [taskIds, dragState, id]); - const newTasks = reorderTasks( - tasks, - active.id as string, - over.id as string, - projection.depth - ); - onTaskReorder(newTasks); - }, - [tasks, onTaskReorder, offsetLeft, resetState] + // Get task nodes to build execution data for height calculation + const taskNodes = useStore((state) => + state.nodes.filter((n) => n.type === 'task' && n.parentId === id) ); - const handleDragCancel = useCallback(() => { - resetState(); - }, [resetState]); + // Build task execution data record from task nodes + const taskExecutionData = useMemo(() => { + const data: Record }> = {}; + for (const node of taskNodes) { + data[node.id] = { execution: node.data?.execution as Record | undefined }; + } + return data; + }, [taskNodes]); + + // Parallel groups for brackets - recalculates with placeholder included + const parallelGroups = useMemo(() => { + const hasStageExecution = !!stageDuration; + + return displayTaskIds + .map((group, index) => { + const isParallel = group.length > 1; + if (!isParallel) return null; + + const y = calculateParallelGroupY( + index, + displayTaskIds, + hasStageExecution, + taskExecutionData + ); + const height = calculateParallelGroupHeight(group, taskExecutionData); - const taskWidthStyle = useMemo( - () => - taskWidth - ? ({ - '--stage-task-width': `${taskWidth}px`, - '--stage-task-width-parallel': `${taskWidth - INDENTATION_WIDTH}px`, - } as React.CSSProperties) - : undefined, - [taskWidth] - ); + return { + index, + y, + height, + }; + }) + .filter((g): g is NonNullable => g !== null); + }, [displayTaskIds, stageDuration, taskExecutionData]); - const dragOverlayStyle = useMemo( - () => ({ - transform: `scale(${zoom})`, - transformOrigin: 'top left', - }), - [zoom] - ); + // Calculate content height based on displayTaskIds (includes placeholder during drag, updates when tasks added) + const contentHeight = useMemo(() => { + if (displayTaskIds.length === 0) return 60; // Minimum height for empty state + + // Pass taskNodes and stage execution - calculateStageContentHeight will extract execution data + return calculateStageContentHeight(displayTaskIds, taskNodes); + }, [displayTaskIds, taskNodes, execution]); return ( - // biome-ignore lint/a11y/useKeyWithClickEvents: moved over - // biome-ignore lint/a11y/noStaticElementInteractions: moved over -
- - - - {icon} - - - - - setIsStageTitleEditing(true), - onInput: handleStageTitleChange, - onKeyDown: handleStageTitleKeyDown, - onBlur: handleStageTitleBlurToSave, - })} - readOnly={!isStageTitleEditable} - /> - - - - {stageDuration && ( + + {/* biome-ignore lint/a11y/useKeyWithClickEvents: Stage node click handling */} + {/* biome-ignore lint/a11y/noStaticElementInteractions: Stage node interactions */} +
+ {/* Render parallel brackets outside StageContainer to align with React Flow TaskNodes */} + {parallelGroups.map(({ index, y, height }) => { + // y is absolute from stage top, no need to subtract anything + // Bracket and tasks are both positioned relative to this outer div + const absoluteY = y; + const absoluteX = + DEFAULT_TASK_POSITION_CONFIG.contentPaddingX + + DEFAULT_TASK_POSITION_CONFIG.parallelIndent; + + // Bracket Row positioning: + // Tasks at x=40, Row gap=12px, bracket width=4px, bracket margin-left=12px + // So Row left = 40 - 12 - 4 - 12 = 12px + const bracketX = absoluteX - 12 - 4 - 12; + + return ( + + + {/* Create a positioned container for the label to attach to */} +
+ + Parallel + +
+
+ ); + })} + + {/* Drop placeholder now rendered as React Flow node in the hook */} + + + + + {icon} + - {stageDuration} + + + setIsStageTitleEditing(true), + onInput: handleStageTitleChange, + onKeyDown: handleStageTitleKeyDown, + onBlur: handleStageTitleBlurToSave, + })} + readOnly={!isStageTitleEditable} + /> + + - )} - - - - {status && ( - - - - - - )} - {(onTaskAdd || onAddTaskFromToolbox) && !isReadOnly && ( - - - - {addTaskLoading ? ( - - ) : ( - - )} + {stageDuration} + + )} + + + + {status && ( + + + - - - )} - - - - - {!tasks || tasks.length === 0 ? ( - - {(onTaskAdd || onAddTaskFromToolbox) && !isReadOnly ? ( - - {defaultContent} - - ) : ( - - {defaultContent} - + )} - - ) : ( - - - {/* Disable dragging and panning the canvas when dragging a task */} - - {tasks.map((taskGroup, groupIndex) => { - const isParallel = taskGroup.length > 1; - return ( - - {isParallel && } - - {isParallel && ( - - - Parallel - - - )} - {taskGroup.map((task, taskIndex) => { - const taskExecution = execution?.taskStatus?.[task.id]; - return ( - 1, - (tasks[groupIndex + 1]?.length ?? 0) > 1 - )} - onTaskClick={handleTaskClick} - projectedDepth={ - task.id === activeDragId && projected - ? projected.depth - : undefined - } - isDragDisabled={!onTaskReorder} - zoom={zoom} - {...((onTaskGroupModification || onReplaceTaskFromToolbox) && { - onMenuOpen: () => { - taskStateReference.current = { - isParallel, - groupIndex, - taskIndex, - }; - }, - })} - /> - ); - })} - - - ); - })} - - - {createPortal( - - {activeTask ? ( -
- - - -
- ) : null} -
, - document.body + {(onTaskAdd || onAddTaskFromToolbox) && !isReadOnly && ( + + + + {addTaskLoading ? ( + + ) : ( + + )} + + + )} -
- )} -
-
- - {onAddTaskFromToolbox && ( - - setIsAddingTask(false)} - onItemSelect={handleAddTaskToolboxItemSelected} - onSearch={onTaskToolboxSearch} - /> - - )} + + + + + {!displayTaskIds || displayTaskIds.length === 0 ? ( + + {(onTaskAdd || onAddTaskFromToolbox) && !isReadOnly ? ( + + {defaultContent} + + ) : ( + + {defaultContent} + + )} + + ) : ( + + {/* Task nodes render themselves via React Flow parentId relationship */} + {/* Brackets are rendered outside StageContainer to align with TaskNodes */} + + )} + + - {onReplaceTaskFromToolbox && ( - + setIsReplacingTask(false)} - onItemSelect={handleReplaceTaskToolboxItemSelected} + onClose={handleToolboxClose} + onItemSelect={handleToolboxItemSelected} onSearch={onTaskToolboxSearch} /> - )} - {menuItems && !dragging && ( - - )} + {menuItems && !dragging && ( + + )} - {handleElements} -
+ {handleElements} +
+ ); }; diff --git a/packages/apollo-react/src/canvas/components/StageNode/StageNode.types.ts b/packages/apollo-react/src/canvas/components/StageNode/StageNode.types.ts index 1f9ff12f9..7c500fa4d 100644 --- a/packages/apollo-react/src/canvas/components/StageNode/StageNode.types.ts +++ b/packages/apollo-react/src/canvas/components/StageNode/StageNode.types.ts @@ -1,5 +1,4 @@ import type { NodeProps } from '@uipath/apollo-react/canvas/xyflow/react'; -import type { GroupModificationType } from '../../utils/GroupModificationUtils'; import type { NodeMenuItem } from '../NodeContextMenu'; import type { ListItem, ToolboxSearchHandler } from '../Toolbox'; @@ -23,6 +22,11 @@ export interface StageTaskItem { icon?: React.ReactElement; } +/** + * Props for StageNode when using React Flow TaskNodes as children. + * Tasks are rendered as separate React Flow nodes with parentId pointing to the stage. + * Positions are calculated based on order, not user drag position. + */ export interface StageNodeProps extends NodeProps { dragging: boolean; selected: boolean; @@ -31,6 +35,8 @@ export interface StageNodeProps extends NodeProps { groupIndex: number; taskIndex: number; }; + /** The node type for this stage (e.g., "case-management:Stage") */ + nodeType?: string; stageDetails: { label: string; defaultContent?: string; @@ -41,13 +47,14 @@ export interface StageNodeProps extends NodeProps { escalationsTriggered?: boolean; isException?: boolean; isReadOnly?: boolean; - tasks: StageTaskItem[][]; + /** + * 2D array of task IDs (instead of task objects). + * Inner arrays with length > 1 are parallel groups. + * Tasks are rendered as separate React Flow nodes. + */ + taskIds: string[][]; selectedTasks?: string[]; }; - addTaskLabel?: string; - addTaskLoading?: boolean; - replaceTaskLabel?: string; - taskOptions?: ListItem[]; execution?: { stageStatus: { status?: StageStatus; @@ -56,20 +63,57 @@ export interface StageNodeProps extends NodeProps { }; taskStatus: Record; }; + addTaskLabel?: string; + addTaskLoading?: boolean; + taskOptions?: ListItem[]; menuItems?: NodeMenuItem[]; onStageClick?: () => void; onTaskAdd?: () => void; onAddTaskFromToolbox?: (taskItem: ListItem) => void; onTaskToolboxSearch?: ToolboxSearchHandler; - onTaskClick?: (taskElementId: string) => void; - onTaskGroupModification?: ( - groupModificationType: GroupModificationType, - groupIndex: number, - taskIndex: number - ) => void; - onStageTitleChange?: (newTitle: string) => void; - onTaskReorder?: (reorderedTasks: StageTaskItem[][]) => void; + replaceTaskLabel?: string; onReplaceTaskFromToolbox?: (newTask: ListItem, groupIndex: number, taskIndex: number) => void; + /** + * External trigger to open the replace task toolbox. + * Set by the consumer when a TaskNode requests replacement. + */ + replaceTaskTarget?: { groupIndex: number; taskIndex: number } | null; + /** + * Called when the replace toolbox closes so the consumer can clear replaceTaskTarget. + */ + onReplaceTaskTargetChange?: (target: { groupIndex: number; taskIndex: number } | null) => void; + onTaskClick?: (taskId: string) => void; + onTaskSelect?: (taskId: string) => void; + onStageTitleChange?: (newTitle: string) => void; + /** + * Called when task order changes (reorder, cross-stage move, etc.) + * @param newTaskIds - The new 2D array of task IDs + */ + onTaskIdsChange?: (newTaskIds: string[][]) => void; + /** + * Called when a task is moved from another stage to this stage + * @param taskId - The ID of the task being moved + * @param sourceStageId - The ID of the source stage + * @param position - The target position in the task array + */ + onTaskMoveIn?: ( + taskId: string, + sourceStageId: string, + position: { groupIndex: number; taskIndex: number } + ) => void; + /** + * Called when a task is copied from another stage to this stage + * @param taskId - The ID of the original task + * @param newTaskId - The ID for the copied task + * @param sourceStageId - The ID of the source stage + * @param position - The target position in the task array + */ + onTaskCopyIn?: ( + taskId: string, + newTaskId: string, + sourceStageId: string, + position: { groupIndex: number; taskIndex: number } + ) => void; } export interface StageTaskExecution { diff --git a/packages/apollo-react/src/canvas/components/StageNode/StageNode.utils.test.ts b/packages/apollo-react/src/canvas/components/StageNode/StageNode.utils.test.ts deleted file mode 100644 index 06a9ed719..000000000 --- a/packages/apollo-react/src/canvas/components/StageNode/StageNode.utils.test.ts +++ /dev/null @@ -1,570 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import type { NodeMenuAction, NodeMenuItem } from '../NodeContextMenu'; -import { INDENTATION_WIDTH } from './StageNode.styles'; -import type { StageTaskItem } from './StageNode.types'; -import { - buildTaskGroups, - type FlattenedTask, - flattenTasks, - getProjection, - reorderTasks, -} from './StageNode.utils'; -import { transformMenuItems } from './StageNodeTaskUtilities'; - -const createTask = (id: string, label?: string): StageTaskItem => ({ - id, - label: label ?? `Task ${id}`, -}); - -describe('StageNode.utils', () => { - describe('flattenTasks', () => { - it('returns empty array for empty input', () => { - const result = flattenTasks([]); - expect(result).toEqual([]); - }); - - it('returns empty array for array with empty groups', () => { - const result = flattenTasks([[], []]); - expect(result).toEqual([]); - }); - - it('flattens single task with depth 0', () => { - const tasks: StageTaskItem[][] = [[createTask('1')]]; - const result = flattenTasks(tasks); - - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - id: '1', - task: createTask('1'), - groupIndex: 0, - taskIndex: 0, - depth: 0, - }); - }); - - it('flattens multiple single-task groups with depth 0', () => { - const tasks: StageTaskItem[][] = [[createTask('1')], [createTask('2')], [createTask('3')]]; - const result = flattenTasks(tasks); - - expect(result).toHaveLength(3); - expect(result[0]?.depth).toBe(0); - expect(result[1]?.depth).toBe(0); - expect(result[2]?.depth).toBe(0); - expect(result[0]?.groupIndex).toBe(0); - expect(result[1]?.groupIndex).toBe(1); - expect(result[2]?.groupIndex).toBe(2); - }); - - it('flattens parallel tasks (multi-task group) with depth 1', () => { - const tasks: StageTaskItem[][] = [[createTask('1'), createTask('2')]]; - const result = flattenTasks(tasks); - - expect(result).toHaveLength(2); - expect(result[0]?.depth).toBe(1); - expect(result[1]?.depth).toBe(1); - expect(result[0]?.groupIndex).toBe(0); - expect(result[1]?.groupIndex).toBe(0); - expect(result[0]?.taskIndex).toBe(0); - expect(result[1]?.taskIndex).toBe(1); - }); - - it('handles mixed single and parallel groups', () => { - const tasks: StageTaskItem[][] = [ - [createTask('1')], - [createTask('2'), createTask('3')], - [createTask('4')], - ]; - const result = flattenTasks(tasks); - - expect(result).toHaveLength(4); - expect(result[0]?.depth).toBe(0); - expect(result[1]?.depth).toBe(1); - expect(result[2]?.depth).toBe(1); - expect(result[3]?.depth).toBe(0); - }); - - it('preserves task references', () => { - const task = createTask('1', 'Original Task'); - const tasks: StageTaskItem[][] = [[task]]; - const result = flattenTasks(tasks); - - expect(result[0]?.task).toBe(task); - }); - - it('skips null/undefined groups and tasks', () => { - const tasks: StageTaskItem[][] = [ - [createTask('1')], - undefined as unknown as StageTaskItem[], - [createTask('2')], - ]; - const result = flattenTasks(tasks); - - expect(result).toHaveLength(2); - }); - }); - - describe('buildTaskGroups', () => { - it('returns empty array for empty input', () => { - const result = buildTaskGroups([]); - expect(result).toEqual([]); - }); - - it('builds single task group from depth 0 item', () => { - const flattened: FlattenedTask[] = [ - { id: '1', task: createTask('1'), groupIndex: 0, taskIndex: 0, depth: 0 }, - ]; - const result = buildTaskGroups(flattened); - - expect(result).toHaveLength(1); - expect(result[0]).toHaveLength(1); - expect(result[0]?.[0]?.id).toBe('1'); - }); - - it('builds separate groups for consecutive depth 0 items', () => { - const flattened: FlattenedTask[] = [ - { id: '1', task: createTask('1'), groupIndex: 0, taskIndex: 0, depth: 0 }, - { id: '2', task: createTask('2'), groupIndex: 1, taskIndex: 0, depth: 0 }, - ]; - const result = buildTaskGroups(flattened); - - expect(result).toHaveLength(2); - expect(result[0]).toHaveLength(1); - expect(result[1]).toHaveLength(1); - }); - - it('groups consecutive depth 1 items together', () => { - const flattened: FlattenedTask[] = [ - { id: '1', task: createTask('1'), groupIndex: 0, taskIndex: 0, depth: 1 }, - { id: '2', task: createTask('2'), groupIndex: 0, taskIndex: 1, depth: 1 }, - { id: '3', task: createTask('3'), groupIndex: 0, taskIndex: 2, depth: 1 }, - ]; - const result = buildTaskGroups(flattened); - - expect(result).toHaveLength(1); - expect(result[0]).toHaveLength(3); - }); - - it('splits groups when depth changes from 1 to 0', () => { - const flattened: FlattenedTask[] = [ - { id: '1', task: createTask('1'), groupIndex: 0, taskIndex: 0, depth: 1 }, - { id: '2', task: createTask('2'), groupIndex: 0, taskIndex: 1, depth: 1 }, - { id: '3', task: createTask('3'), groupIndex: 1, taskIndex: 0, depth: 0 }, - ]; - const result = buildTaskGroups(flattened); - - expect(result).toHaveLength(2); - expect(result[0]).toHaveLength(2); - expect(result[1]).toHaveLength(1); - }); - - it('splits groups when depth changes from 0 to 1', () => { - const flattened: FlattenedTask[] = [ - { id: '1', task: createTask('1'), groupIndex: 0, taskIndex: 0, depth: 0 }, - { id: '2', task: createTask('2'), groupIndex: 1, taskIndex: 0, depth: 1 }, - { id: '3', task: createTask('3'), groupIndex: 1, taskIndex: 1, depth: 1 }, - ]; - const result = buildTaskGroups(flattened); - - expect(result).toHaveLength(2); - expect(result[0]).toHaveLength(1); - expect(result[1]).toHaveLength(2); - }); - - it('handles complex mixed depth sequences', () => { - const flattened: FlattenedTask[] = [ - { id: '1', task: createTask('1'), groupIndex: 0, taskIndex: 0, depth: 0 }, - { id: '2', task: createTask('2'), groupIndex: 1, taskIndex: 0, depth: 1 }, - { id: '3', task: createTask('3'), groupIndex: 1, taskIndex: 1, depth: 1 }, - { id: '4', task: createTask('4'), groupIndex: 2, taskIndex: 0, depth: 0 }, - { id: '5', task: createTask('5'), groupIndex: 3, taskIndex: 0, depth: 0 }, - ]; - const result = buildTaskGroups(flattened); - - expect(result).toHaveLength(4); - expect(result[0]).toHaveLength(1); - expect(result[1]).toHaveLength(2); - expect(result[2]).toHaveLength(1); - expect(result[3]).toHaveLength(1); - }); - }); - - describe('reorderTasks', () => { - it('returns original tasks when activeId not found', () => { - const tasks: StageTaskItem[][] = [[createTask('1')], [createTask('2')]]; - const result = reorderTasks(tasks, 'nonexistent', '2', 0); - - expect(result).toEqual(tasks); - }); - - it('returns original tasks when overId not found', () => { - const tasks: StageTaskItem[][] = [[createTask('1')], [createTask('2')]]; - const result = reorderTasks(tasks, '1', 'nonexistent', 0); - - expect(result).toEqual(tasks); - }); - - it('reorders tasks within same position (no change)', () => { - const tasks: StageTaskItem[][] = [[createTask('1')], [createTask('2')]]; - const result = reorderTasks(tasks, '1', '1', 0); - - expect(result).toHaveLength(2); - expect(result[0]?.[0]?.id).toBe('1'); - expect(result[1]?.[0]?.id).toBe('2'); - }); - - it('moves task down in the list', () => { - const tasks: StageTaskItem[][] = [[createTask('1')], [createTask('2')], [createTask('3')]]; - const result = reorderTasks(tasks, '1', '3', 0); - - expect(result).toHaveLength(3); - expect(result[0]?.[0]?.id).toBe('2'); - expect(result[1]?.[0]?.id).toBe('3'); - expect(result[2]?.[0]?.id).toBe('1'); - }); - - it('moves task up in the list', () => { - const tasks: StageTaskItem[][] = [[createTask('1')], [createTask('2')], [createTask('3')]]; - const result = reorderTasks(tasks, '3', '1', 0); - - expect(result).toHaveLength(3); - expect(result[0]?.[0]?.id).toBe('3'); - expect(result[1]?.[0]?.id).toBe('1'); - expect(result[2]?.[0]?.id).toBe('2'); - }); - - it('applies projectedDepth to moved task', () => { - const tasks: StageTaskItem[][] = [[createTask('1')], [createTask('2')]]; - const result = reorderTasks(tasks, '1', '2', 1); - - expect(result).toHaveLength(2); - expect(result[0]?.[0]?.id).toBe('2'); - expect(result[1]?.[0]?.id).toBe('1'); - }); - - it('maintains separate groups when projectedDepth is 0', () => { - const tasks: StageTaskItem[][] = [[createTask('1'), createTask('2')]]; - const result = reorderTasks(tasks, '1', '2', 0); - - expect(result).toHaveLength(2); - }); - - it('handles reordering within parallel group', () => { - const tasks: StageTaskItem[][] = [[createTask('1'), createTask('2'), createTask('3')]]; - const result = reorderTasks(tasks, '1', '3', 1); - - expect(result).toHaveLength(1); - expect(result[0]).toHaveLength(3); - expect(result[0]?.[0]?.id).toBe('2'); - expect(result[0]?.[1]?.id).toBe('3'); - expect(result[0]?.[2]?.id).toBe('1'); - }); - }); - - describe('getProjection', () => { - it('returns null when activeId not found', () => { - const tasks: StageTaskItem[][] = [[createTask('1')], [createTask('2')]]; - const result = getProjection(tasks, 'nonexistent', '2', 0); - - expect(result).toBeNull(); - }); - - it('returns null when overId not found', () => { - const tasks: StageTaskItem[][] = [[createTask('1')], [createTask('2')]]; - const result = getProjection(tasks, '1', 'nonexistent', 0); - - expect(result).toBeNull(); - }); - - it('returns depth 0 for dragging in place on single task', () => { - const tasks: StageTaskItem[][] = [[createTask('1')], [createTask('2')], [createTask('3')]]; - const result = getProjection(tasks, '2', '2', 0); - - expect(result).not.toBeNull(); - expect(result?.depth).toBe(0); - expect(result?.maxDepth).toBe(1); - }); - - it('returns projected depth when dragging in place adjacent to parallel group', () => { - const tasks: StageTaskItem[][] = [ - [createTask('1')], - [createTask('2'), createTask('3')], - [createTask('4')], - ]; - const result = getProjection(tasks, '1', '1', INDENTATION_WIDTH); - - expect(result).not.toBeNull(); - expect(result?.depth).toBe(1); - }); - - it('returns depth 0 for no offset when not adjacent to parallel', () => { - const tasks: StageTaskItem[][] = [[createTask('1')], [createTask('2')], [createTask('3')]]; - const result = getProjection(tasks, '2', '2', 0); - - expect(result).not.toBeNull(); - expect(result?.depth).toBe(0); - }); - - it('clamps depth to maxDepth when dragging over parallel group', () => { - const tasks: StageTaskItem[][] = [[createTask('1')], [createTask('2'), createTask('3')]]; - const result = getProjection(tasks, '1', '2', INDENTATION_WIDTH * 5); - - expect(result).not.toBeNull(); - expect(result?.depth).toBeLessThanOrEqual(result?.maxDepth ?? 0); - }); - - it('returns correct groupIndex and taskIndex', () => { - const tasks: StageTaskItem[][] = [ - [createTask('1')], - [createTask('2'), createTask('3')], - [createTask('4')], - ]; - const result = getProjection(tasks, '1', '3', 0); - - expect(result).not.toBeNull(); - expect(result?.groupIndex).toBe(1); - expect(result?.taskIndex).toBe(1); - }); - - it('handles dragging down into parallel group', () => { - const tasks: StageTaskItem[][] = [[createTask('1')], [createTask('2'), createTask('3')]]; - const result = getProjection(tasks, '1', '2', 0); - - expect(result).not.toBeNull(); - expect(result?.depth).toBe(1); - }); - - it('handles dragging up into parallel group', () => { - const tasks: StageTaskItem[][] = [[createTask('1'), createTask('2')], [createTask('3')]]; - const result = getProjection(tasks, '3', '2', 0); - - expect(result).not.toBeNull(); - expect(result?.depth).toBe(1); - }); - - it('allows depth control on edge of parallel group when dragging down', () => { - const tasks: StageTaskItem[][] = [ - [createTask('1')], - [createTask('2'), createTask('3')], - [createTask('4')], - ]; - const result = getProjection(tasks, '1', '3', 0); - - expect(result).not.toBeNull(); - expect(result?.depth).toBe(0); - }); - - it('allows depth control on edge of parallel group when dragging up', () => { - const tasks: StageTaskItem[][] = [ - [createTask('1')], - [createTask('2'), createTask('3')], - [createTask('4')], - ]; - const result = getProjection(tasks, '4', '2', 0); - - expect(result).not.toBeNull(); - expect(result?.depth).toBe(0); - }); - - it('handles dragging within parallel group - middle item stays at depth 1', () => { - const tasks: StageTaskItem[][] = [[createTask('1'), createTask('2'), createTask('3')]]; - const result = getProjection(tasks, '1', '2', 0); - - expect(result).not.toBeNull(); - expect(result?.depth).toBe(1); - }); - - it('handles dragging from parallel last item to adjacent single task going down', () => { - const tasks: StageTaskItem[][] = [[createTask('1'), createTask('2')], [createTask('3')]]; - const result = getProjection(tasks, '2', '3', INDENTATION_WIDTH); - - expect(result).not.toBeNull(); - expect(result?.depth).toBe(0); - }); - - it('handles dragging from parallel first item to adjacent single task going up', () => { - const tasks: StageTaskItem[][] = [[createTask('1')], [createTask('2'), createTask('3')]]; - const result = getProjection(tasks, '2', '1', INDENTATION_WIDTH); - - expect(result).not.toBeNull(); - expect(result?.depth).toBe(0); - }); - }); - - describe('flattenTasks and buildTaskGroups roundtrip', () => { - it('preserves structure for single tasks', () => { - const original: StageTaskItem[][] = [[createTask('1')], [createTask('2')], [createTask('3')]]; - const flattened = flattenTasks(original); - const rebuilt = buildTaskGroups(flattened); - - expect(rebuilt).toHaveLength(original.length); - for (let i = 0; i < original.length; i++) { - expect(rebuilt[i]).toHaveLength(original[i]?.length ?? 0); - expect(rebuilt[i]?.[0]?.id).toBe(original[i]?.[0]?.id); - } - }); - - it('preserves structure for parallel groups', () => { - const original: StageTaskItem[][] = [[createTask('1'), createTask('2'), createTask('3')]]; - const flattened = flattenTasks(original); - const rebuilt = buildTaskGroups(flattened); - - expect(rebuilt).toHaveLength(1); - expect(rebuilt[0]).toHaveLength(3); - }); - - it('preserves structure for mixed groups', () => { - const original: StageTaskItem[][] = [ - [createTask('1')], - [createTask('2'), createTask('3')], - [createTask('4')], - [createTask('5'), createTask('6')], - ]; - const flattened = flattenTasks(original); - const rebuilt = buildTaskGroups(flattened); - - expect(rebuilt).toHaveLength(4); - expect(rebuilt[0]).toHaveLength(1); - expect(rebuilt[1]).toHaveLength(2); - expect(rebuilt[2]).toHaveLength(1); - expect(rebuilt[3]).toHaveLength(2); - }); - }); - - describe('transformMenuItems', () => { - it('returns empty array for undefined menuItems', () => { - const onItemClick = vi.fn(); - const result = transformMenuItems(undefined, onItemClick); - - expect(result).toEqual([]); - }); - - it('transforms a single action item', () => { - const menuItems: NodeMenuItem[] = [ - { - id: 'test-1', - label: 'Test Action', - onClick: vi.fn(), - }, - ]; - const onItemClick = vi.fn(); - const result = transformMenuItems(menuItems, onItemClick); - - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - key: 'test-1', - title: 'Test Action', - variant: 'item', - disabled: undefined, - }); - expect(result[0]?.onClick).toBeDefined(); - }); - - it('transforms multiple action items', () => { - const menuItems: NodeMenuItem[] = [ - { id: 'action-1', label: 'Action 1', onClick: vi.fn() }, - { id: 'action-2', label: 'Action 2', onClick: vi.fn() }, - { id: 'action-3', label: 'Action 3', onClick: vi.fn() }, - ]; - const onItemClick = vi.fn(); - const result = transformMenuItems(menuItems, onItemClick); - - expect(result).toHaveLength(3); - expect(result[0]?.key).toBe('action-1'); - expect(result[1]?.key).toBe('action-2'); - expect(result[2]?.key).toBe('action-3'); - }); - - it('transforms divider items correctly', () => { - const menuItems: NodeMenuItem[] = [ - { id: 'action-1', label: 'Action 1', onClick: vi.fn() }, - { type: 'divider' }, - { id: 'action-2', label: 'Action 2', onClick: vi.fn() }, - ]; - const onItemClick = vi.fn(); - const result = transformMenuItems(menuItems, onItemClick); - - expect(result).toHaveLength(3); - expect(result[0]?.variant).toBe('item'); - expect(result[1]?.variant).toBe('separator'); - expect(result[1]?.divider).toBe(true); - expect(result[1]?.key).toBe('divider-1'); - expect(result[2]?.variant).toBe('item'); - }); - - it('preserves disabled state', () => { - const menuItems: NodeMenuItem[] = [ - { id: 'action-1', label: 'Enabled Action', onClick: vi.fn(), disabled: false }, - { id: 'action-2', label: 'Disabled Action', onClick: vi.fn(), disabled: true }, - ]; - const onItemClick = vi.fn(); - const result = transformMenuItems(menuItems, onItemClick); - - expect(result[0]?.disabled).toBe(false); - expect(result[1]?.disabled).toBe(true); - }); - - it('preserves icon when provided', () => { - const iconElement = 'icon'; - const menuItems: NodeMenuItem[] = [ - { id: 'action-1', label: 'With Icon', onClick: vi.fn(), icon: iconElement }, - ]; - const onItemClick = vi.fn(); - const result = transformMenuItems(menuItems, onItemClick); - - expect(result[0]?.startIcon).toBe(iconElement); - }); - - it('calls onItemClick when transformed onClick is invoked', () => { - const originalOnClick = vi.fn(); - const menuItems: NodeMenuItem[] = [ - { id: 'action-1', label: 'Test', onClick: originalOnClick }, - ]; - const onItemClick = vi.fn(); - const result = transformMenuItems(menuItems, onItemClick); - - result[0]?.onClick?.(); - - expect(onItemClick).toHaveBeenCalledTimes(1); - expect(onItemClick).toHaveBeenCalledWith({ - id: 'action-1', - label: 'Test', - onClick: originalOnClick, - }); - }); - - it('handles mixed items and dividers', () => { - const menuItems: NodeMenuItem[] = [ - { id: 'action-1', label: 'Action 1', onClick: vi.fn() }, - { id: 'action-2', label: 'Action 2', onClick: vi.fn() }, - { type: 'divider' }, - { id: 'action-3', label: 'Action 3', onClick: vi.fn() }, - { type: 'divider' }, - { id: 'action-4', label: 'Action 4', onClick: vi.fn() }, - ]; - const onItemClick = vi.fn(); - const result = transformMenuItems(menuItems, onItemClick); - - expect(result).toHaveLength(6); - expect(result[0]?.variant).toBe('item'); - expect(result[1]?.variant).toBe('item'); - expect(result[2]?.variant).toBe('separator'); - expect(result[3]?.variant).toBe('item'); - expect(result[4]?.variant).toBe('separator'); - expect(result[5]?.variant).toBe('item'); - }); - - it('generates unique keys for multiple dividers', () => { - const menuItems: NodeMenuItem[] = [ - { type: 'divider' }, - { type: 'divider' }, - { type: 'divider' }, - ]; - const onItemClick = vi.fn(); - const result = transformMenuItems(menuItems, onItemClick); - - expect(result[0]?.key).toBe('divider-0'); - expect(result[1]?.key).toBe('divider-1'); - expect(result[2]?.key).toBe('divider-2'); - }); - }); -}); diff --git a/packages/apollo-react/src/canvas/components/StageNode/StageNode.utils.ts b/packages/apollo-react/src/canvas/components/StageNode/StageNode.utils.ts deleted file mode 100644 index a7076cce8..000000000 --- a/packages/apollo-react/src/canvas/components/StageNode/StageNode.utils.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { arrayMove } from '@dnd-kit/sortable'; -import { INDENTATION_WIDTH } from './StageNode.styles'; -import type { StageTaskItem } from './StageNode.types'; - -export interface FlattenedTask { - id: string; - task: StageTaskItem; - groupIndex: number; - taskIndex: number; - depth: number; -} - -export interface ProjectionResult { - depth: number; - maxDepth: number; - groupIndex: number; - taskIndex: number; -} - -export function flattenTasks(tasks: StageTaskItem[][]): FlattenedTask[] { - const flattened: FlattenedTask[] = []; - - for (const [groupIndex, group] of tasks.entries()) { - if (!group) continue; - const depth = group.length > 1 ? 1 : 0; - - for (const [taskIndex, task] of group.entries()) { - if (!task) continue; - flattened.push({ - id: task.id, - task, - groupIndex, - taskIndex, - depth, - }); - } - } - - return flattened; -} - -export function buildTaskGroups(flattenedTasks: FlattenedTask[]): StageTaskItem[][] { - const groups: StageTaskItem[][] = []; - let currentGroup: StageTaskItem[] = []; - let previousDepth: number | null = null; - let previousGroupIndex: number | null = null; - - for (const item of flattenedTasks) { - if (previousDepth === null) { - currentGroup.push(item.task); - } else if (item.depth === 1 && previousDepth === 1 && item.groupIndex === previousGroupIndex) { - currentGroup.push(item.task); - } else { - if (currentGroup.length > 0) { - groups.push(currentGroup); - } - currentGroup = [item.task]; - } - previousDepth = item.depth; - previousGroupIndex = item.groupIndex; - } - - if (currentGroup.length > 0) { - groups.push(currentGroup); - } - - return groups; -} - -export function reorderTasks( - tasks: StageTaskItem[][], - activeId: string, - overId: string, - projectedDepth: number -): StageTaskItem[][] { - const flattened = flattenTasks(tasks); - - const activeIndex = flattened.findIndex((t) => t.id === activeId); - const overIndex = flattened.findIndex((t) => t.id === overId); - - if (activeIndex === -1 || overIndex === -1) { - return tasks; - } - - const activeTask = flattened[activeIndex]; - if (!activeTask) { - return tasks; - } - - const reordered = arrayMove(flattened, activeIndex, overIndex); - const movedItem = reordered[overIndex]; - if (movedItem) { - let newGroupIndex = movedItem.groupIndex; - - if (projectedDepth === 1) { - const prevItem = reordered[overIndex - 1]; - const nextItem = reordered[overIndex + 1]; - if (prevItem?.depth === 1) { - newGroupIndex = prevItem.groupIndex; - } else if (nextItem?.depth === 1) { - newGroupIndex = nextItem.groupIndex; - } - } - - reordered[overIndex] = { ...movedItem, depth: projectedDepth, groupIndex: newGroupIndex }; - } - - return buildTaskGroups(reordered); -} - -function getDragDepth(offsetLeft: number): number { - return Math.round(offsetLeft / INDENTATION_WIDTH); -} - -function getParallelGroupInfo( - tasks: StageTaskItem[][], - groupIndex: number, - taskIndex: number -): { isParallel: boolean; isFirstItem: boolean; isLastItem: boolean } { - const group = tasks[groupIndex]; - if (!group || group.length <= 1) { - return { isParallel: false, isFirstItem: false, isLastItem: false }; - } - return { - isParallel: true, - isFirstItem: taskIndex === 0, - isLastItem: taskIndex === group.length - 1, - }; -} - -export function getProjection( - tasks: StageTaskItem[][], - activeId: string, - overId: string, - offsetLeft: number -): ProjectionResult | null { - const flattened = flattenTasks(tasks); - - const activeIndex = flattened.findIndex((t) => t.id === activeId); - const overIndex = flattened.findIndex((t) => t.id === overId); - - if (activeIndex === -1 || overIndex === -1) { - return null; - } - - const activeTask = flattened[activeIndex]; - const overTask = flattened[overIndex]; - - if (!activeTask || !overTask) { - return null; - } - - const overInfo = getParallelGroupInfo(tasks, overTask.groupIndex, overTask.taskIndex); - - const minDepth = 0; - const maxDepth = 1; - let depth = 0; - - const isActiveParallel = activeTask.depth === 1; - const dragDepth = isActiveParallel ? (offsetLeft > 0 ? 1 : 0) : getDragDepth(offsetLeft); - - const isDraggingDown = activeIndex < overIndex; - const isDraggingUp = activeIndex > overIndex; - const isDraggingInPlace = activeIndex === overIndex; - - if (isDraggingInPlace) { - if (overInfo.isParallel) { - const isOnBorder = overInfo.isFirstItem || overInfo.isLastItem; - if (isOnBorder) { - depth = Math.max(minDepth, Math.min(maxDepth, dragDepth)); - } else { - depth = maxDepth; - } - } else { - const nextTask = flattened[overIndex + 1]; - const prevTask = flattened[overIndex - 1]; - const isAdjacentToParallel = nextTask?.depth === 1 || prevTask?.depth === 1; - if (isAdjacentToParallel) { - depth = Math.max(minDepth, Math.min(maxDepth, dragDepth)); - } - } - } else if (overInfo.isParallel) { - const isEdgeInsertion = - (isDraggingUp && overInfo.isFirstItem) || (isDraggingDown && overInfo.isLastItem); - - if (isEdgeInsertion) { - depth = Math.max(minDepth, Math.min(maxDepth, dragDepth)); - } else { - depth = maxDepth; - } - } else { - const nextTask = flattened[overIndex + 1]; - const prevTask = flattened[overIndex - 1]; - const isAdjacentToParallel = - (isDraggingDown && nextTask?.depth === 1) || (isDraggingUp && prevTask?.depth === 1); - - if (isAdjacentToParallel) { - depth = Math.max(minDepth, Math.min(maxDepth, dragDepth)); - } - } - - return { - depth, - maxDepth, - groupIndex: overTask.groupIndex, - taskIndex: overTask.taskIndex, - }; -} diff --git a/packages/apollo-react/src/canvas/components/StageNode/StageNodeTaskUtilities.ts b/packages/apollo-react/src/canvas/components/StageNode/StageNodeTaskUtilities.ts index 7386907a8..8e8a60e2b 100644 --- a/packages/apollo-react/src/canvas/components/StageNode/StageNodeTaskUtilities.ts +++ b/packages/apollo-react/src/canvas/components/StageNode/StageNodeTaskUtilities.ts @@ -16,9 +16,13 @@ export const getContextMenuItems = ( groupModificationType: GroupModificationType, groupIndex: number, taskIndex: number - ) => void + ) => void, + onReplaceTask: (groupIndex: number, taskIndex: number) => void ): NodeMenuItem[] => { const CONTEXT_MENU_ITEMS = { + REPLACE_TASK: getMenuItem('replace-task', 'Replace task', () => + onReplaceTask(groupIndex, taskIndex) + ), MOVE_UP: getMenuItem('move-up', 'Move up', () => reGroupTaskFunction(GroupModificationType.TASK_GROUP_UP, groupIndex, taskIndex) ), @@ -62,6 +66,8 @@ export const getContextMenuItems = ( const items: NodeMenuItem[] = []; + items.push(CONTEXT_MENU_ITEMS.REPLACE_TASK, CONTEXT_MENU_ITEMS.DIVIDER); + if (groupIndex > 0) items.push(CONTEXT_MENU_ITEMS.MOVE_UP); if (groupIndex < tasksLength - 1) items.push(CONTEXT_MENU_ITEMS.MOVE_DOWN); diff --git a/packages/apollo-react/src/canvas/components/StageNode/TaskMenu.tsx b/packages/apollo-react/src/canvas/components/StageNode/TaskMenu.tsx index 0e242852a..ed9c4771b 100644 --- a/packages/apollo-react/src/canvas/components/StageNode/TaskMenu.tsx +++ b/packages/apollo-react/src/canvas/components/StageNode/TaskMenu.tsx @@ -1,81 +1,37 @@ -import { Spacing } from '@uipath/apollo-core'; +import { Padding, Spacing } from '@uipath/apollo-core'; import { ApIcon, ApIconButton, ApMenu } from '@uipath/apollo-react/material'; -import { - forwardRef, - memo, - useCallback, - useImperativeHandle, - useMemo, - useRef, - useState, -} from 'react'; +import { memo, useCallback, useMemo, useRef, useState } from 'react'; import type { NodeMenuAction, NodeMenuItem } from '../NodeContextMenu'; import { transformMenuItems } from './StageNodeTaskUtilities'; -import token from '@uipath/apollo-core'; - -export interface TaskMenuHandle { - handleContextMenu: (e: React.MouseEvent) => void; -} interface TaskMenuProps { taskId: string; contextMenuItems: NodeMenuItem[]; onMenuOpenChange?: (isOpen: boolean) => void; - onMenuOpen?: () => void; - taskRef?: React.RefObject; } -const TaskMenuComponent = ( - { taskId, contextMenuItems, onMenuOpenChange, onMenuOpen, taskRef }: TaskMenuProps, - ref: React.Ref -) => { +const TaskMenuComponent = ({ taskId, contextMenuItems, onMenuOpenChange }: TaskMenuProps) => { const [isMenuOpen, setIsMenuOpen] = useState(false); - const [anchorElement, setAnchorElement] = useState(null); const menuAnchorRef = useRef(null); const handleMenuClose = useCallback(() => { setIsMenuOpen(false); - setAnchorElement(null); onMenuOpenChange?.(false); }, [onMenuOpenChange]); - const openMenu = useCallback( - (anchor: HTMLElement | null) => { - setAnchorElement(anchor); - setIsMenuOpen(true); - onMenuOpen?.(); - onMenuOpenChange?.(true); - }, - [onMenuOpen, onMenuOpenChange] - ); - const handleMenuClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); - if (isMenuOpen) { - handleMenuClose(); - } else { - openMenu(menuAnchorRef.current); - } + setIsMenuOpen((open) => { + const newState = !open; + onMenuOpenChange?.(newState); + return newState; + }); }, - [isMenuOpen, handleMenuClose, openMenu] + [onMenuOpenChange] ); - const handleContextMenu = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - const anchor = taskRef?.current || (e.currentTarget as HTMLElement); - openMenu(anchor); - }, - [taskRef, openMenu] - ); - - useImperativeHandle(ref, () => ({ - handleContextMenu, - })); - const handleMenuMouseDown = useCallback((e: React.MouseEvent) => { e.stopPropagation(); }, []); @@ -113,7 +69,7 @@ const TaskMenuComponent = ( { + const width = data.isParallel + ? 'var(--stage-task-width-parallel, 246px)' // Parallel: 272 - 26 = 246px + : 'var(--stage-task-width, 272px)'; // Sequential: full width 272px + + return ( +
+ ); +}; diff --git a/packages/apollo-react/src/canvas/components/TaskNode/TaskNode.stories.tsx b/packages/apollo-react/src/canvas/components/TaskNode/TaskNode.stories.tsx new file mode 100644 index 000000000..3e17998c4 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/TaskNode/TaskNode.stories.tsx @@ -0,0 +1,526 @@ +/** + * TaskNode Stories + * + * Demonstrates TaskNode component as React Flow nodes within StageNode containers. + * All stories support drag to reorder and copy/paste functionality. + */ + +import type { Meta, StoryObj } from '@storybook/react'; +import type { Node } from '@uipath/apollo-react/canvas/xyflow/react'; +import { + ConnectionMode, + Panel, + ReactFlowProvider, + useEdgesState, + useNodesState, +} from '@uipath/apollo-react/canvas/xyflow/react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { CrossStageDragProvider } from '../../hooks/CrossStageDragContext'; +import { type TaskReorderParams, useCrossStageTaskDrag } from '../../hooks/useCrossStageTaskDrag'; +import { type TaskPasteParams, useTaskCopyPaste } from '../../hooks/useTaskCopyPaste'; +import { DefaultCanvasTranslations } from '../../types'; +import { BaseCanvas } from '../BaseCanvas'; +import { CanvasPositionControls } from '../CanvasPositionControls'; +import { StageConnectionEdge } from '../StageNode/StageConnectionEdge'; +import { StageEdge } from '../StageNode/StageEdge'; +import type { StageNodeProps } from '../StageNode/StageNode.types'; +import { StageNode } from '../StageNode/StageNode'; +import { TaskIcon, TaskItemTypeValues } from '../TaskIcon'; +import { PlaceholderTaskNode } from './PlaceholderTaskNode'; +import { TaskNode } from './TaskNode'; +import type { TaskNodeData, TaskNode as TaskNodeType } from './TaskNode.types'; +import { reorderTaskIds } from './taskReorderUtils'; +import { calculateTaskPositions } from './useTaskPositions'; + +const meta: Meta = { + title: 'Canvas/StageNode/TaskNode', + parameters: { + layout: 'fullscreen', + }, +}; + +export default meta; +type Story = StoryObj; + +// Wrapper for StageNode that spreads data props +const StageNodeWrapper = (props: any) => { + return ; +}; + +// Wrapper for TaskNode that spreads data props +const TaskNodeWrapper = (props: any) => { + return ; +}; + +// Helper to create task nodes from task IDs +interface TaskInfo { + label: string; + taskType: string; + icon: React.ReactElement; + execution?: TaskNodeData['execution']; +} + +function createTaskNodes( + stageId: string, + taskIds: string[][], + tasks: Record, + stageWidth: number, + onTaskClick?: (taskId: string) => void, + onTaskSelect?: (taskId: string) => void +): TaskNodeType[] { + // Pass tasks directly - calculate functions will extract execution data + const positions = calculateTaskPositions(taskIds, stageWidth, tasks as any); + const nodes: TaskNodeType[] = []; + + taskIds.forEach((group, groupIndex) => { + group.forEach((taskId, taskIndex) => { + const taskInfo = tasks[taskId]; + if (!taskInfo) return; + + const position = positions.get(taskId); + if (!position) return; + + nodes.push({ + id: taskId, + type: 'task', + parentId: stageId, + extent: 'parent', + position: { x: position.x, y: position.y }, + width: position.width, // Set explicit width on React Flow node + data: { + taskType: taskInfo.taskType, + label: taskInfo.label, + iconElement: taskInfo.icon, + groupIndex, + taskIndex, + execution: taskInfo.execution, + onTaskClick, + onTaskSelect, + width: position.width, // Also pass through data for component to use + } as TaskNodeData, + }); + }); + }); + + return nodes; +} + +// Shared component for interactive canvas with drag/copy/paste +interface InteractiveCanvasProps { + initialTaskIds: string[][]; + tasks: Record; + stageWidth: number; + stageLabel: string; + showInstructions?: boolean; +} + +const InteractiveCanvas = ({ + initialTaskIds, + tasks: initialTasks, + stageWidth, + stageLabel, + showInstructions = true, +}: InteractiveCanvasProps) => { + const [taskIds, setTaskIds] = useState(initialTaskIds); + const [tasks, setTasks] = useState(initialTasks); + const [selectedTaskId, setSelectedTaskId] = useState(null); + const [actionLog, setActionLog] = useState(''); + + // Create nodes from current taskIds state + // During drag, insert placeholder to make tasks shift + const displayTaskIds = useMemo(() => { + return taskIds; // Will be modified during drag in InteractiveCanvasInner + }, [taskIds]); + + const stageNode: Node = useMemo( + () => ({ + id: 'stage-1', + type: 'stage', + position: { x: 250, y: 100 }, + style: { width: stageWidth }, + data: { + nodeType: 'case-management:Stage', + stageDetails: { + label: stageLabel, + taskIds: displayTaskIds, + }, + onTaskClick: (taskId: string) => { + setSelectedTaskId(taskId); + setActionLog(`Selected: ${taskId}`); + }, + } as Partial, + }), + [displayTaskIds, stageLabel, stageWidth] + ); + + // Callbacks to pass through data + const handleTaskClick = useCallback((taskId: string) => { + setSelectedTaskId(taskId); + setActionLog(`Selected: ${taskId}`); + }, []); + + const taskNodes = useMemo( + () => + createTaskNodes( + 'stage-1', + displayTaskIds, + tasks, + stageWidth, + handleTaskClick, + setSelectedTaskId + ), + [displayTaskIds, tasks, stageWidth, handleTaskClick] + ); + + const allNodes = useMemo(() => [stageNode, ...taskNodes], [stageNode, taskNodes]); + + const [nodes, setNodes, onNodesChange] = useNodesState(allNodes); + const [edges, , onEdgesChange] = useEdgesState([]); + + // Update nodes when taskIds change + useEffect(() => { + setNodes(allNodes); + }, [allNodes, setNodes]); + + const nodeTypes = useMemo( + () => ({ + stage: StageNodeWrapper, + task: TaskNodeWrapper, + placeholder: (props: any) => , + }), + [] + ); + const edgeTypes = useMemo(() => ({ stage: StageEdge }), []); + + // Handle task reordering within the same stage + const handleTaskReorder = useCallback((params: TaskReorderParams) => { + const { taskId, position } = params; + const depthLabel = position.isParallel ? 'parallel' : 'sequential'; + setActionLog(`Reordered ${taskId} to group ${position.groupIndex} (${depthLabel})`); + + setTaskIds((prev) => { + // Use the proper reorder logic that handles flatten/arrayMove/buildGroups + const depth = position.isParallel ? 1 : 0; + return reorderTaskIds(prev, taskId, position.groupIndex, position.taskIndex, depth); + }); + }, []); + + // Handle task paste (from keyboard shortcut) + const handleTaskPaste = useCallback((params: TaskPasteParams) => { + const { newTaskId, originalData } = params; + setActionLog(`Pasted as ${newTaskId}`); + + // Add task data to tasks object + setTasks((prev) => ({ + ...prev, + [newTaskId]: { + label: `${originalData.label} (Copy)`, + taskType: originalData.taskType as string, + icon: originalData.iconElement ?? ( + + ), + execution: originalData.execution, + } as TaskInfo, + })); + + // Add to taskIds at the end + setTaskIds((prev) => { + return [...prev, [newTaskId]]; + }); + }, []); + + return ( + + + + ); +}; + +// Inner component that uses React Flow hooks +interface InteractiveCanvasInnerProps { + nodes: Node[]; + edges: any[]; + onNodesChange: any; + onEdgesChange: any; + nodeTypes: any; + edgeTypes: any; + onTaskReorder: (params: TaskReorderParams) => void; + onTaskPaste: (params: TaskPasteParams) => void; + selectedTaskId: string | null; + targetTaskIds: string[][]; + actionLog: string; + showInstructions: boolean; +} + +const InteractiveCanvasInner = ({ + nodes, + edges, + onNodesChange, + onEdgesChange, + nodeTypes, + edgeTypes, + onTaskReorder, + onTaskPaste, + selectedTaskId, + targetTaskIds, + actionLog, + showInstructions, +}: InteractiveCanvasInnerProps) => { + // Use drag hook for drag operations (now includes task shifting) + const { dragState, handlers } = useCrossStageTaskDrag({ + onTaskReorder, + }); + + // Use copy/paste hook for keyboard shortcuts + useTaskCopyPaste( + { onTaskPaste }, + { + selectedTaskId, + targetStageId: 'stage-1', + targetTaskIds, + enabled: true, + } + ); + + return ( + + + + + + {showInstructions && ( + +
+ Instructions: +
    +
  • Click a task to select it
  • +
  • Drag tasks to reorder
  • +
  • Ctrl/Cmd+C to copy selected task
  • +
  • Ctrl/Cmd+V to paste task
  • +
+
+
+ )} + {actionLog && ( + +
+ {actionLog} +
+
+ )} + {dragState.isDragging && ( + +
+ Dragging {dragState.taskId} + {dragState.dropPosition && ( +
+ → Group {dragState.dropPosition.groupIndex} + {dragState.dropPosition.isParallel ? ' (Parallel)' : ' (Sequential)'} +
+ Drag right to make parallel +
+
+ )} +
+
+ )} +
+
+ ); +}; + +/** + * Basic TaskNode example showing sequential tasks within a StageNode + * Now with full drag and copy/paste support + */ +export const Basic: Story = { + render: () => { + const tasks: Record = { + 'task-1': { + label: 'Process Document', + taskType: 'uipath.case-management.process', + icon: , + }, + 'task-2': { + label: 'Run Human Action', + taskType: 'uipath.case-management.run-human-action', + icon: , + }, + 'task-3': { + label: 'Execute RPA', + taskType: 'uipath.case-management.rpa', + icon: , + }, + }; + + return ( +
+ +
+ ); + }, +}; + +/** + * TaskNodes with execution status and badges + * Now with full drag and copy/paste support + */ +export const WithExecutionStatus: Story = { + render: () => { + const tasks: Record = { + 'task-1': { + label: 'Completed Task', + taskType: 'uipath.case-management.process', + icon: , + execution: { + status: 'Completed', + duration: '2m 30s', + }, + }, + 'task-2': { + label: 'In Progress Task', + taskType: 'uipath.case-management.run-human-action', + icon: , + execution: { + status: 'InProgress', + duration: '1m 15s', + }, + }, + 'task-3': { + label: 'Failed Task with Retries', + taskType: 'uipath.case-management.rpa', + icon: , + execution: { + status: 'Failed', + duration: '0m 45s', + retryDuration: '0m 30s', + message: 'Connection timeout', + badge: 'Error', + badgeStatus: 'error', + retryCount: 2, + }, + }, + 'task-4': { + label: 'Pending Task', + taskType: 'uipath.case-management.action', + icon: , + execution: { + status: 'NotExecuted', + }, + }, + }; + + return ( +
+ +
+ ); + }, +}; + +/** + * Demonstrates parallel task grouping with proper positioning + * Fully interactive with drag support + */ +export const ParallelTasks: Story = { + render: () => { + const tasks: Record = { + 'task-1': { + label: 'Pre-check', + taskType: 'uipath.case-management.action', + icon: , + }, + 'task-2': { + label: 'Address Verification', + taskType: 'uipath.case-management.process', + icon: , + }, + 'task-3': { + label: 'Property Verification', + taskType: 'uipath.case-management.process', + icon: , + }, + 'task-4': { + label: 'Background Check', + taskType: 'uipath.case-management.process', + icon: , + }, + 'task-5': { + label: 'Final Review', + taskType: 'uipath.case-management.agent', + icon: , + }, + }; + + return ( +
+ +
+ ); + }, +}; diff --git a/packages/apollo-react/src/canvas/components/TaskNode/TaskNode.tsx b/packages/apollo-react/src/canvas/components/TaskNode/TaskNode.tsx new file mode 100644 index 000000000..097fffe17 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/TaskNode/TaskNode.tsx @@ -0,0 +1,204 @@ +import { FontVariantToken, Padding, Spacing } from '@uipath/apollo-core'; +import { Column, Row } from '@uipath/apollo-react/canvas/layouts'; +import { + ApBadge, + ApTooltip, + ApTypography, + BadgeSize, + type StatusTypes, +} from '@uipath/apollo-react/material'; +import { memo, useCallback, useState } from 'react'; +import { ExecutionStatusIcon } from '../ExecutionStatusIcon'; +import { + StageTask, + StageTaskIcon, + StageTaskRetryDuration, +} from '../StageNode/StageNode.styles'; +import type { StageTaskExecution } from '../StageNode/StageNode.types'; +import { TaskMenu } from '../StageNode/TaskMenu'; +import { useIsTaskParallel, useOptionalTaskNodeContext } from './TaskNodeContext'; +import type { TaskNodeData, TaskNodeProps } from './TaskNode.types'; + +/** + * Default icon for tasks without a custom icon + */ +const DefaultTaskIcon = () => ( + + + + + + +); + +/** + * Generate badge text with retry count + */ +const generateBadgeText = (taskExecution: StageTaskExecution) => { + if (!taskExecution.badge) { + return undefined; + } + if (taskExecution.retryCount && taskExecution.retryCount > 1) { + return `${taskExecution.badge} x${taskExecution.retryCount}`; + } + return taskExecution.badge; +}; + +/** + * Props for the task content component + */ +interface TaskContentProps { + taskId: string; + data: TaskNodeData; + isDragging?: boolean; + isHovered?: boolean; +} + +/** + * Inner content of a task node (label, icon, status, badges) + */ +export const TaskNodeContent = memo(({ taskId, data, isDragging, isHovered }: TaskContentProps) => { + const { label, iconElement, execution, contextMenuItems, onMenuOpenChange } = data; + const showMenu = isHovered && contextMenuItems && contextMenuItems.length > 0; + + return ( + + + + {iconElement ?? } + {/* Disable tooltip when dragging to avoid flickering */} + + + {label} + + + + + {showMenu && contextMenuItems && ( + + )} + {execution?.status && execution.status !== 'NotExecuted' && + (execution.message ? ( + + + + ) : ( + + ))} + + + {execution && ( + + + {execution?.duration && ( + + {execution.duration} + + )} + {execution?.retryDuration && ( + + + {`(+${execution.retryDuration})`} + + + )} + + {execution?.badge && ( + + )} + + )} + + ); +}); + +TaskNodeContent.displayName = 'TaskNodeContent'; + +/** + * TaskNode component for React Flow + * + * This component renders a task as a React Flow node. Tasks are child nodes + * of stage nodes, with positions calculated based on order (not user drag position). + * + * Key features: + * - Uses parentId to establish stage-task relationship + * - Position is calculated by useTaskPositions, not dragged by user + * - Reuses StageTask styled component for visual consistency + * - Supports selection, hover, and drag states + */ +const TaskNodeComponent = ({ id, data, selected, dragging }: TaskNodeProps) => { + const [isHovered, setIsHovered] = useState(false); + + // Get parallel status from context (if available) + const isParallel = useIsTaskParallel(id); + const context = useOptionalTaskNodeContext(); + + // Tasks with execution status are not draggable + const hasExecution = !!data.execution; + + const handleMouseEnter = useCallback(() => setIsHovered(true), []); + const handleMouseLeave = useCallback(() => setIsHovered(false), []); + + const handleClick = useCallback(() => { + // Use data.onTaskClick as fallback when context is null (React Flow renders nodes outside context) + const clickHandler = context?.onTaskClick || data.onTaskClick; + const selectHandler = context?.onTaskSelect || data.onTaskSelect; + clickHandler?.(id); + selectHandler?.(id); + }, [context, id, data.onTaskClick, data.onTaskSelect]); + + return ( + // biome-ignore lint/a11y/useKeyWithClickEvents: Task node click handling + + + + ); +}; + +export const TaskNode = memo(TaskNodeComponent); +TaskNode.displayName = 'TaskNode'; diff --git a/packages/apollo-react/src/canvas/components/TaskNode/TaskNode.types.ts b/packages/apollo-react/src/canvas/components/TaskNode/TaskNode.types.ts new file mode 100644 index 000000000..f8d06ca6e --- /dev/null +++ b/packages/apollo-react/src/canvas/components/TaskNode/TaskNode.types.ts @@ -0,0 +1,98 @@ +import type { Node, NodeProps } from '@xyflow/react'; +import type { NodeMenuItem } from '../NodeContextMenu'; +import type { StageTaskExecution } from '../StageNode/StageNode.types'; + +/** + * Existing task types from the codebase + * Use with "uipath.case-management." prefix + */ +export type TaskType = + | 'process' + | 'agent' + | 'external-agent' + | 'rpa' + | 'action' + | 'api-workflow' + | 'wait-for-timer' + | 'wait-for-connector' + | 'run-human-action' + | 'execute-connector-activity'; + +/** + * Data stored on a TaskNode + * Includes index signature for React Flow compatibility + */ +export interface TaskNodeData extends Record { + /** Task type identifier (e.g., "uipath.case-management.run-human-action") */ + taskType: string; + /** Display label for the task */ + label: string; + /** Icon identifier string (resolved via iconResolver) */ + icon?: string; + /** React element for the icon (alternative to icon string) */ + iconElement?: React.ReactElement; + /** Group index within the stage (for parallel grouping) */ + groupIndex: number; + /** Task index within the group */ + taskIndex: number; + /** Execution state for the task */ + execution?: StageTaskExecution; + /** Click handler - passed through data since context doesn't work across React Flow layers */ + onTaskClick?: (taskId: string) => void; + /** Select handler - passed through data since context doesn't work across React Flow layers */ + onTaskSelect?: (taskId: string) => void; + /** Explicit width for proper sizing (CSS variables don't cascade to React Flow nodes) */ + width?: number; + /** Context menu items for the task menu */ + contextMenuItems?: NodeMenuItem[]; + /** Callback when menu open state changes */ + onMenuOpenChange?: (isOpen: boolean) => void; +} + +/** + * A TaskNode in the React Flow graph + */ +export type TaskNode = Node; + +/** + * Props passed to the TaskNode component by React Flow + */ +export interface TaskNodeProps extends NodeProps { + /** Whether the node is currently being dragged */ + dragging: boolean; + /** Whether the node is selected */ + selected: boolean; +} + +/** + * Position information for a task within a stage + */ +export interface TaskPosition { + x: number; + y: number; + width: number; +} + +/** + * Configuration for task positioning + */ +export interface TaskPositionConfig { + /** Thickness of the stage border */ + stageBorderThickness: number; + /** Task height in pixels */ + taskHeight: number; + /** Gap between tasks */ + taskGap: number; + /** Indentation for parallel tasks */ + parallelIndent: number; + /** Padding at the top of content area */ + contentPaddingTop: number; + /** Padding at the bottom of content area */ + contentPaddingBottom: number; + /** Horizontal padding in content area */ + contentPaddingX: number; + /** Height of the stage header */ + headerHeight: number; + /** Height of the execution description */ + headerExecutionDescriptionHeight: number; +} diff --git a/packages/apollo-react/src/canvas/components/TaskNode/TaskNodeContext.tsx b/packages/apollo-react/src/canvas/components/TaskNode/TaskNodeContext.tsx new file mode 100644 index 000000000..11500ab5e --- /dev/null +++ b/packages/apollo-react/src/canvas/components/TaskNode/TaskNodeContext.tsx @@ -0,0 +1,103 @@ +import { createContext, useContext, type ReactNode } from 'react'; + +/** + * Context value for task nodes within a stage + */ +export interface TaskNodeContextValue { + /** Stage ID that contains this task */ + stageId: string; + /** Stage node type (e.g., "case-management:Stage") */ + stageNodeType: string; + /** 2D array of task IDs - used to determine parallel grouping */ + taskIds: string[][]; + /** Whether the stage is read-only */ + isReadOnly?: boolean; + /** Callback when a task is clicked */ + onTaskClick?: (taskId: string) => void; + /** Callback when a task is selected */ + onTaskSelect?: (taskId: string) => void; +} + +const TaskNodeContext = createContext(null); + +/** + * Provider for task node context + */ +export interface TaskNodeProviderProps { + children: ReactNode; + value: TaskNodeContextValue; +} + +export function TaskNodeProvider({ children, value }: TaskNodeProviderProps) { + return ( + + {children} + + ); +} + +/** + * Hook to access task node context + * @throws Error if used outside of TaskNodeProvider + */ +export function useTaskNodeContext(): TaskNodeContextValue { + const context = useContext(TaskNodeContext); + if (!context) { + throw new Error('useTaskNodeContext must be used within a TaskNodeProvider'); + } + return context; +} + +/** + * Hook to check if a task is in a parallel group + * @param taskId - The task ID to check + * @returns Whether the task is in a parallel group (group has more than 1 task) + */ +export function useIsTaskParallel(taskId: string): boolean { + const context = useContext(TaskNodeContext); + if (!context) return false; + + for (const group of context.taskIds) { + if (group.includes(taskId)) { + return group.length > 1; + } + } + return false; +} + +/** + * Hook to get group information for a task + * @param taskId - The task ID to look up + * @returns Group information or null if not found + */ +export function useTaskGroupInfo(taskId: string): { + groupIndex: number; + taskIndex: number; + isParallel: boolean; + groupSize: number; +} | null { + const context = useContext(TaskNodeContext); + if (!context) return null; + + for (let groupIndex = 0; groupIndex < context.taskIds.length; groupIndex++) { + const group = context.taskIds[groupIndex]; + if (!group) continue; + const taskIndex = group.indexOf(taskId); + if (taskIndex !== -1) { + return { + groupIndex, + taskIndex, + isParallel: group.length > 1, + groupSize: group.length, + }; + } + } + return null; +} + +/** + * Hook to safely access task node context (returns null if not in provider) + */ +export function useOptionalTaskNodeContext(): TaskNodeContextValue | null { + return useContext(TaskNodeContext); +} diff --git a/packages/apollo-react/src/canvas/components/TaskNode/index.ts b/packages/apollo-react/src/canvas/components/TaskNode/index.ts new file mode 100644 index 000000000..350ee30b2 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/TaskNode/index.ts @@ -0,0 +1,25 @@ +export { TaskNode, TaskNodeContent } from './TaskNode'; +export { PlaceholderTaskNode } from './PlaceholderTaskNode'; +export { + calculateTaskPositions, + calculateStageContentHeight, + useTaskPositions, + DEFAULT_TASK_POSITION_CONFIG, +} from './useTaskPositions'; +export { + TaskNodeProvider, + useTaskNodeContext, + useOptionalTaskNodeContext, + useIsTaskParallel, + useTaskGroupInfo, +} from './TaskNodeContext'; +export type { + TaskType, + TaskNodeData, + TaskNode as TaskNodeType, + TaskNodeProps, + TaskPosition, + TaskPositionConfig, +} from './TaskNode.types'; +export type { TaskNodeContextValue, TaskNodeProviderProps } from './TaskNodeContext'; +export { flattenTaskIds, buildTaskIdGroups, reorderTaskIds } from './taskReorderUtils'; diff --git a/packages/apollo-react/src/canvas/components/TaskNode/taskReorderUtils.test.ts b/packages/apollo-react/src/canvas/components/TaskNode/taskReorderUtils.test.ts new file mode 100644 index 000000000..f797476f4 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/TaskNode/taskReorderUtils.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect } from 'vitest'; +import { reorderTaskIds, flattenTaskIds, moveTaskWithinStage } from './taskReorderUtils'; + +describe('taskReorderUtils', () => { + describe('reorderTaskIds', () => { + it('moves sequential task to end as sequential', () => { + // Original: [['task-1'], ['task-2', 'task-3'], ['task-4']] + // Move task-1 to end (after filtering: [['task-2', 'task-3'], ['task-4']]) + // Target: groupIndex=2, taskIndex=0, depth=0 (sequential) + const taskIds = [['task-1'], ['task-2', 'task-3'], ['task-4']]; + const result = reorderTaskIds(taskIds, 'task-1', 2, 0, 0); + + // Expected: [['task-2', 'task-3'], ['task-4'], ['task-1']] + expect(result).toEqual([['task-2', 'task-3'], ['task-4'], ['task-1']]); + }); + + it('moves sequential task to N-1 position (before last task)', () => { + // Original: [['task-1'], ['task-2', 'task-3'], ['task-4']] + // Move task-1 to position before task-4 + // Filtered: [['task-2', 'task-3'], ['task-4']] + // Target: groupIndex=1, taskIndex=0, depth=0 (sequential, before the sequential task-4) + const taskIds = [['task-1'], ['task-2', 'task-3'], ['task-4']]; + const result = reorderTaskIds(taskIds, 'task-1', 1, 0, 0); + + // Expected: [['task-2', 'task-3'], ['task-1'], ['task-4']] + expect(result).toEqual([['task-2', 'task-3'], ['task-1'], ['task-4']]); + }); + + it('moves task to join parallel group at end', () => { + // Original: [['task-1'], ['task-2', 'task-3']] + // Move task-1 to join the parallel group + // Filtered: [['task-2', 'task-3']] + // Target: groupIndex=0, taskIndex=2, depth=1 (parallel, at end of group) + const taskIds = [['task-1'], ['task-2', 'task-3']]; + const result = reorderTaskIds(taskIds, 'task-1', 1, 0, 1); + + // Expected: [['task-2', 'task-3', 'task-1']] - joined the parallel group + expect(result).toEqual([['task-2', 'task-3', 'task-1']]); + }); + + it('keeps task in parallel group when reordering within it', () => { + // Original: [['task-1', 'task-2', 'task-3']] + // Move task-1 to end of group + // Filtered: [['task-2', 'task-3']] + // Target: groupIndex=0, taskIndex=2, depth=1 + const taskIds = [['task-1', 'task-2', 'task-3']]; + const result = reorderTaskIds(taskIds, 'task-1', 0, 2, 1); + + // Expected: [['task-2', 'task-3', 'task-1']] + expect(result).toEqual([['task-2', 'task-3', 'task-1']]); + }); + + it('allows task to break out of parallel group to sequential', () => { + // Original: [['task-1', 'task-2']] + // Move task-1 to be sequential after task-2 + // Filtered: [['task-2']] + // Target: groupIndex=1, taskIndex=0, depth=0 + const taskIds = [['task-1', 'task-2']]; + const result = reorderTaskIds(taskIds, 'task-1', 1, 0, 0); + + // Expected: [['task-2'], ['task-1']] + expect(result).toEqual([['task-2'], ['task-1']]); + }); + + it('preserves parallel groups when moving between them', () => { + // Original: [['task-1', 'task-2'], ['task-3'], ['task-4', 'task-5']] + // Move task-1 to before task-3 (sequential) + // Filtered: [['task-2'], ['task-3'], ['task-4', 'task-5']] + // Target: groupIndex=1, taskIndex=0, depth=0 + const taskIds = [['task-1', 'task-2'], ['task-3'], ['task-4', 'task-5']]; + const result = reorderTaskIds(taskIds, 'task-1', 1, 0, 0); + + // Expected: [['task-2'], ['task-1'], ['task-3'], ['task-4', 'task-5']] + expect(result).toEqual([['task-2'], ['task-1'], ['task-3'], ['task-4', 'task-5']]); + }); + }); + + describe('flattenTaskIds', () => { + it('flattens sequential tasks', () => { + const taskIds = [['task-1'], ['task-2'], ['task-3']]; + const flattened = flattenTaskIds(taskIds); + + expect(flattened).toEqual([ + { id: 'task-1', groupIndex: 0, taskIndex: 0, depth: 0 }, + { id: 'task-2', groupIndex: 1, taskIndex: 0, depth: 0 }, + { id: 'task-3', groupIndex: 2, taskIndex: 0, depth: 0 }, + ]); + }); + + it('flattens parallel groups with depth 1', () => { + const taskIds = [['task-1', 'task-2'], ['task-3']]; + const flattened = flattenTaskIds(taskIds); + + expect(flattened).toEqual([ + { id: 'task-1', groupIndex: 0, taskIndex: 0, depth: 1 }, + { id: 'task-2', groupIndex: 0, taskIndex: 1, depth: 1 }, + { id: 'task-3', groupIndex: 1, taskIndex: 0, depth: 0 }, + ]); + }); + }); + + describe('moveTaskWithinStage', () => { + it('moves bottom task up in parallel group without affecting sequential task above', () => { + // Original: [['task-1'], ['task-2', 'task-3']] - sequential then parallel + // Drag task-3 (bottom of parallel group) up + // Position calculated by convertToGroupPosition: groupIndex=1, taskIndex=0, isParallel=true + const taskIds = [['task-1'], ['task-2', 'task-3']]; + const result = moveTaskWithinStage(taskIds, 'task-3', { + groupIndex: 1, + taskIndex: 0, + isParallel: true, + }); + + // task-1 should remain separate, task-3 should join task-2's group at position 0 + expect(result).toEqual([['task-1'], ['task-3', 'task-2']]); + }); + + it('moves bottom task up in parallel group without joining parallel group above', () => { + // Original: [['task-1', 'task-2'], ['task-3', 'task-4']] - two parallel groups + // Drag task-4 (bottom of second group) up + // Position: groupIndex=1, taskIndex=0, isParallel=true + const taskIds = [['task-1', 'task-2'], ['task-3', 'task-4']]; + const result = moveTaskWithinStage(taskIds, 'task-4', { + groupIndex: 1, + taskIndex: 0, + isParallel: true, + }); + + // First parallel group should remain unchanged, task-4 joins task-3's group + expect(result).toEqual([['task-1', 'task-2'], ['task-4', 'task-3']]); + }); + + it('correctly reorders within the same parallel group', () => { + // Original: [['task-1', 'task-2', 'task-3']] + // Move task-1 to end of group + const taskIds = [['task-1', 'task-2', 'task-3']]; + const result = moveTaskWithinStage(taskIds, 'task-1', { + groupIndex: 0, + taskIndex: 2, + isParallel: true, + }); + + expect(result).toEqual([['task-2', 'task-3', 'task-1']]); + }); + + it('breaks out of parallel group to sequential', () => { + // Original: [['task-1', 'task-2']] + // Move task-1 to be sequential after task-2 + const taskIds = [['task-1', 'task-2']]; + const result = moveTaskWithinStage(taskIds, 'task-1', { + groupIndex: 1, + taskIndex: 0, + isParallel: false, + }); + + expect(result).toEqual([['task-2'], ['task-1']]); + }); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/TaskNode/taskReorderUtils.ts b/packages/apollo-react/src/canvas/components/TaskNode/taskReorderUtils.ts new file mode 100644 index 000000000..917c9ff97 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/TaskNode/taskReorderUtils.ts @@ -0,0 +1,329 @@ +/** + * Utility functions for reordering tasks within a stage using TaskNode approach + * Mirrors the logic from StageNode.utils.ts but works with taskIds instead of task objects + */ + +/** + * Position for inserting a task + */ +export interface TaskInsertPosition { + groupIndex: number; + taskIndex: number; + isParallel: boolean; +} + +interface FlattenedTaskId { + id: string; + groupIndex: number; + taskIndex: number; + depth: number; // 0 = sequential, 1 = parallel +} + +/** + * Flatten taskIds array into a flat list with metadata + */ +export function flattenTaskIds(taskIds: string[][]): FlattenedTaskId[] { + const flattened: FlattenedTaskId[] = []; + + for (const [groupIndex, group] of taskIds.entries()) { + if (!group) continue; + const depth = group.length > 1 ? 1 : 0; + + for (const [taskIndex, id] of group.entries()) { + if (!id) continue; + flattened.push({ + id, + groupIndex, + taskIndex, + depth, + }); + } + } + + return flattened; +} + +/** + * Build taskIds array from flattened list + */ +export function buildTaskIdGroups(flattenedTaskIds: FlattenedTaskId[]): string[][] { + const groups: string[][] = []; + let currentGroup: string[] = []; + let previousDepth: number | null = null; + let previousGroupIndex: number | null = null; + + for (const item of flattenedTaskIds) { + if (previousDepth === null) { + currentGroup.push(item.id); + } else if (item.depth === 1 && previousDepth === 1 && item.groupIndex === previousGroupIndex) { + // Continue parallel group + currentGroup.push(item.id); + } else { + // Start new group + if (currentGroup.length > 0) { + groups.push(currentGroup); + } + currentGroup = [item.id]; + } + previousDepth = item.depth; + previousGroupIndex = item.groupIndex; + } + + if (currentGroup.length > 0) { + groups.push(currentGroup); + } + + return groups; +} + +/** + * Reorder task IDs within a stage + * + * IMPORTANT: targetGroupIndex and targetTaskIndex are relative to the FILTERED structure + * (with activeId removed), matching how drop positions are calculated in calculateDropPosition. + * + * @param taskIds - Current task ID structure + * @param activeId - ID of task being moved + * @param targetGroupIndex - Target group index (relative to filtered structure) + * @param targetTaskIndex - Target task index within group (relative to filtered structure) + * @param projectedDepth - 0 for sequential, 1 for parallel + */ +export function reorderTaskIds( + taskIds: string[][], + activeId: string, + targetGroupIndex: number, + targetTaskIndex: number, + projectedDepth: number +): string[][] { + const flattened = flattenTaskIds(taskIds); + + const activeIndex = flattened.findIndex((t) => t.id === activeId); + if (activeIndex === -1) return taskIds; + + // Create a filtered flattened list (without the active task) to match how positions are calculated + const filteredFlattened = flattened.filter((t) => t.id !== activeId); + + // Rebuild group indices for filtered list + const filteredTaskIds = taskIds + .map((group) => group.filter((id) => id !== activeId)) + .filter((group) => group.length > 0); + + const filteredWithIndices = flattenTaskIds(filteredTaskIds); + + // Find the target position in the FILTERED flattened array + let targetFilteredIndex = filteredWithIndices.length; // Default to end + + for (let i = 0; i < filteredWithIndices.length; i++) { + const item = filteredWithIndices[i]; + if (!item) continue; + + if (item.groupIndex === targetGroupIndex && item.taskIndex === targetTaskIndex) { + targetFilteredIndex = i; + break; + } + + // If we've passed the target group, insert at this position + if (item.groupIndex > targetGroupIndex) { + targetFilteredIndex = i; + break; + } + } + + // Insert the active task at the target position in the filtered list + const activeItem = flattened[activeIndex]; + if (!activeItem) return taskIds; + + // Determine the new depth and group index + let newGroupIndex = targetGroupIndex; + let newDepth = projectedDepth; + + if (projectedDepth === 1) { + // Joining a parallel group - find which parallel group to join + const prevItem = filteredFlattened[targetFilteredIndex - 1]; + + if (prevItem) { + // Check if previous item in filtered list was originally parallel + const prevOriginal = filteredWithIndices[targetFilteredIndex - 1]; + const prevGroupLength = prevOriginal ? filteredTaskIds[prevOriginal.groupIndex]?.length : 0; + if (prevOriginal && prevGroupLength && prevGroupLength > 0) { + // Find original group for this task + for (const originalGroup of taskIds) { + if (originalGroup.includes(prevItem.id) && originalGroup.length > 1) { + newGroupIndex = prevOriginal.groupIndex; + break; + } + } + } + } + } + + // Build the result by inserting into filtered list + const result: FlattenedTaskId[] = [...filteredFlattened]; + result.splice(targetFilteredIndex, 0, { + ...activeItem, + depth: newDepth, + groupIndex: newGroupIndex, + }); + + // Rebuild group structure + return buildTaskIdGroupsFromReorder(result, projectedDepth, targetFilteredIndex); +} + +/** + * Build taskIds array from reordered flattened list + * Handles proper grouping based on the moved item's depth + */ +function buildTaskIdGroupsFromReorder( + flattenedTaskIds: FlattenedTaskId[], + movedItemDepth: number, + movedItemIndex: number +): string[][] { + const groups: string[][] = []; + let currentGroup: string[] = []; + + for (let i = 0; i < flattenedTaskIds.length; i++) { + const item = flattenedTaskIds[i]; + if (!item) continue; + + const isMovedItem = i === movedItemIndex; + const prevItem = flattenedTaskIds[i - 1]; + + if (currentGroup.length === 0) { + currentGroup.push(item.id); + } else if (isMovedItem && movedItemDepth === 1) { + // Moved item wants to be parallel - join current group + currentGroup.push(item.id); + } else if (isMovedItem && movedItemDepth === 0) { + // Moved item wants to be sequential - start new group + if (currentGroup.length > 0) { + groups.push(currentGroup); + } + currentGroup = [item.id]; + } else if (item.depth === 1 && prevItem?.depth === 1 && !isMovedItem) { + // Regular parallel continuation (not the moved item) + // Check if they were in the same original group + if (item.groupIndex === prevItem.groupIndex) { + currentGroup.push(item.id); + } else { + groups.push(currentGroup); + currentGroup = [item.id]; + } + } else { + // Start new group + groups.push(currentGroup); + currentGroup = [item.id]; + } + } + + if (currentGroup.length > 0) { + groups.push(currentGroup); + } + + return groups; +} + +/** + * Remove a task from taskIds array + * Returns a new array with the task removed and empty groups filtered out + * + * @param taskIds - Current task ID structure + * @param taskId - ID of task to remove + * @returns New taskIds array with task removed + */ +export function removeTaskFromTaskIds(taskIds: string[][], taskId: string): string[][] { + return taskIds + .map((group) => group.filter((id) => id !== taskId)) + .filter((group) => group.length > 0); +} + +/** + * Insert a task at a specific position in taskIds array + * Handles both sequential and parallel insertions + * + * @param taskIds - Current task ID structure + * @param taskId - ID of task to insert + * @param position - Position to insert at (groupIndex, taskIndex, isParallel) + * @returns New taskIds array with task inserted + */ +export function insertTaskAtPosition( + taskIds: string[][], + taskId: string, + position: TaskInsertPosition +): string[][] { + const { groupIndex, taskIndex, isParallel } = position; + const newTaskIds = taskIds.map((group) => [...group]); + + if (groupIndex >= newTaskIds.length) { + // Inserting at or beyond the end + if (isParallel && newTaskIds.length > 0) { + // Append to last group if parallel + const lastGroup = newTaskIds[newTaskIds.length - 1]; + if (lastGroup && lastGroup.length > 0) { + lastGroup.push(taskId); + } else { + newTaskIds.push([taskId]); + } + } else { + // Add as new sequential group at end + newTaskIds.push([taskId]); + } + } else if (isParallel) { + // Insert into existing group as parallel + const targetGroup = newTaskIds[groupIndex]; + if (targetGroup) { + targetGroup.splice(taskIndex, 0, taskId); + } else { + newTaskIds.splice(groupIndex, 0, [taskId]); + } + } else { + // Insert as new sequential group + newTaskIds.splice(groupIndex, 0, [taskId]); + } + + return newTaskIds; +} + +/** + * Move a task from one position to another within the same stage + * Uses remove-then-insert approach to match placeholder logic exactly + * + * @param taskIds - Current task ID structure + * @param taskId - ID of task to move + * @param position - Target position (groupIndex, taskIndex, isParallel) + * @returns New taskIds array with task moved + */ +export function moveTaskWithinStage( + taskIds: string[][], + taskId: string, + position: TaskInsertPosition +): string[][] { + // Remove the task first, then insert at new position + // This matches exactly how placeholder positions are calculated + const withoutTask = removeTaskFromTaskIds(taskIds, taskId); + return insertTaskAtPosition(withoutTask, taskId, position); +} + +/** + * Move a task from one stage to another + * Returns updated taskIds for both source and target stages + * + * @param sourceTaskIds - Task IDs in source stage + * @param targetTaskIds - Task IDs in target stage + * @param taskId - ID of task to move + * @param position - Target position in target stage + * @returns Object with new taskIds for both source and target + */ +export function moveTaskBetweenStages( + sourceTaskIds: string[][], + targetTaskIds: string[][], + taskId: string, + position: TaskInsertPosition +): { sourceTaskIds: string[][]; targetTaskIds: string[][] } { + const newSourceTaskIds = removeTaskFromTaskIds(sourceTaskIds, taskId); + const newTargetTaskIds = insertTaskAtPosition(targetTaskIds, taskId, position); + + return { + sourceTaskIds: newSourceTaskIds, + targetTaskIds: newTargetTaskIds, + }; +} diff --git a/packages/apollo-react/src/canvas/components/TaskNode/useTaskPositions.ts b/packages/apollo-react/src/canvas/components/TaskNode/useTaskPositions.ts new file mode 100644 index 000000000..523fe9edb --- /dev/null +++ b/packages/apollo-react/src/canvas/components/TaskNode/useTaskPositions.ts @@ -0,0 +1,227 @@ +import { useCallback, useMemo } from 'react'; +import type { TaskPosition, TaskPositionConfig } from './TaskNode.types'; + +/** + * Calculate extra height needed for a task based on its execution content + */ +function calculateExecutionExtraHeight(execution: Record): number { + if (execution.duration || execution.retryDuration || execution.badge) { + // Has content in second row + if (execution.retryDuration || execution.badge) { + return 26; // Taller for retry/badge content (62px total) + } else { + return 18; // Normal for just duration (54px total) + } + } else { + return 2; // Minimal for status icon only (38px total) + } +} + +/** + * Default configuration for task positioning + * Values match the existing StageNode implementation + */ +export const DEFAULT_TASK_POSITION_CONFIG: TaskPositionConfig = { + stageBorderThickness: 2, // 2px border thickness + taskHeight: 36, // Base DOM height of task element (matches Figma h-[36px]) + taskGap: 12, // Gap between tasks (matches Figma gap-[12px] for 16px grid system) + parallelIndent: 30, // INDENTATION_WIDTH from StageNode.styles + contentPaddingTop: 16, // Content padding top + contentPaddingBottom: 16, // Content padding bottom + contentPaddingX: 14, // Matches Figma px-[14px] + headerHeight: 56, // Base header height + headerExecutionDescriptionHeight: 16, // Extra height when stage has execution with duration +}; + +/** + * Calculate positions for all tasks within a stage based on their order and grouping. + * Tasks are positioned based on their groupIndex and taskIndex, NOT by user drag position. + * + * @param taskIds - 2D array of task IDs. Inner arrays with length > 1 are parallel groups. + * @param stageWidth - Width of the stage container + * @param config - Position configuration (optional, uses defaults) + * @param taskData - Map of taskId to task data (with optional execution property) or nodes array + * @param stageExecution - Stage execution data (adds +16px to header if has duration) + * @returns Map of taskId -> TaskPosition + */ +export function calculateTaskPositions( + taskIds: string[][], + stageWidth: number, + taskData?: + | Record }> + | Array<{ id: string; data?: { execution?: Record } }>, + stageExecution?: Record +): Map { + const positions = new Map(); + + // Convert taskData to Record for uniform access + const taskRecord: Record }> = {}; + if (taskData) { + if (Array.isArray(taskData)) { + // Extract from nodes array + taskData.forEach((node) => { + taskRecord[node.id] = { execution: node.data?.execution }; + }); + } else { + // Already a Record + Object.assign(taskRecord, taskData); + } + } + + const taskWidth = stageWidth - DEFAULT_TASK_POSITION_CONFIG.contentPaddingX * 2; + const parallelTaskWidth = taskWidth - DEFAULT_TASK_POSITION_CONFIG.parallelIndent; + + // Add extra header height if stage has execution with duration + const headerHeight = stageExecution?.duration + ? DEFAULT_TASK_POSITION_CONFIG.headerHeight + + DEFAULT_TASK_POSITION_CONFIG.headerExecutionDescriptionHeight + : DEFAULT_TASK_POSITION_CONFIG.headerHeight; + + let currentY = + headerHeight + + DEFAULT_TASK_POSITION_CONFIG.contentPaddingTop + + DEFAULT_TASK_POSITION_CONFIG.stageBorderThickness; + + for (const group of taskIds) { + const isParallel = group.length > 1; + + for (let taskIndex = 0; taskIndex < group.length; taskIndex++) { + const taskId = group[taskIndex]; + if (!taskId) continue; + + // Calculate task height based on execution content + const execution = taskRecord[taskId]?.execution; + const taskHeight = execution + ? DEFAULT_TASK_POSITION_CONFIG.taskHeight + calculateExecutionExtraHeight(execution) + : DEFAULT_TASK_POSITION_CONFIG.taskHeight; + + positions.set(taskId, { + x: isParallel + ? DEFAULT_TASK_POSITION_CONFIG.contentPaddingX + + DEFAULT_TASK_POSITION_CONFIG.parallelIndent + : DEFAULT_TASK_POSITION_CONFIG.contentPaddingX, + y: currentY, + width: isParallel ? parallelTaskWidth : taskWidth, + }); + + currentY += taskHeight; + if (taskIndex < group.length - 1) { + currentY += DEFAULT_TASK_POSITION_CONFIG.taskGap; + } + } + + currentY += DEFAULT_TASK_POSITION_CONFIG.taskGap; + } + + return positions; +} + +/** + * Calculate the total height needed for the stage content based on tasks + * + * @param taskIds - 2D array of task IDs + * @param config - Position configuration (optional) + * @param taskData - Map of taskId to task data (with optional execution property) or nodes array + * @param stageExecution - Stage execution data (adds +16px to header if has duration) + * @returns Total height in pixels + */ +export function calculateStageContentHeight( + taskIds: string[][], + taskData?: + | Record }> + | Array<{ id: string; data?: { execution?: Record } }> +): number { + // Convert taskData to Record for uniform access + const taskRecord: Record }> = {}; + if (taskData) { + if (Array.isArray(taskData)) { + taskData.forEach((node) => { + taskRecord[node.id] = { execution: node.data?.execution }; + }); + } else { + Object.assign(taskRecord, taskData); + } + } + + let height = DEFAULT_TASK_POSITION_CONFIG.contentPaddingTop; + + for (const group of taskIds) { + // Calculate height for each task in the group + for (const taskId of group) { + const execution = taskRecord[taskId]?.execution; + const taskHeight = execution + ? DEFAULT_TASK_POSITION_CONFIG.taskHeight + calculateExecutionExtraHeight(execution) + : DEFAULT_TASK_POSITION_CONFIG.taskHeight; + height += taskHeight + DEFAULT_TASK_POSITION_CONFIG.taskGap; + } + } + + // Remove the last gap + if (taskIds.length > 0) { + height -= DEFAULT_TASK_POSITION_CONFIG.taskGap; + height += DEFAULT_TASK_POSITION_CONFIG.contentPaddingBottom; + } + + return height; +} + +/** + * Hook to calculate task positions for a stage + * + * @param taskIds - 2D array of task IDs + * @param stageWidth - Width of the stage + * @param config - Position configuration (optional) + * @returns Object with positions map and helper functions + */ +export function useTaskPositions( + taskIds: string[][], + stageWidth: number, + stageExecution?: Record +) { + const positions = useMemo( + () => calculateTaskPositions(taskIds, stageWidth, undefined, stageExecution), + [taskIds, stageWidth, stageExecution] + ); + + const contentHeight = useMemo(() => calculateStageContentHeight(taskIds, undefined), [taskIds]); + + const getPosition = useCallback( + (taskId: string): TaskPosition | undefined => positions.get(taskId), + [positions] + ); + + const isParallel = useCallback( + (taskId: string): boolean => { + for (const group of taskIds) { + if (group.includes(taskId)) { + return group.length > 1; + } + } + return false; + }, + [taskIds] + ); + + const getGroupInfo = useCallback( + (taskId: string): { groupIndex: number; taskIndex: number } | null => { + for (let groupIndex = 0; groupIndex < taskIds.length; groupIndex++) { + const group = taskIds[groupIndex]; + if (!group) continue; + const taskIndex = group.indexOf(taskId); + if (taskIndex !== -1) { + return { groupIndex, taskIndex }; + } + } + return null; + }, + [taskIds] + ); + + return { + positions, + contentHeight, + getPosition, + isParallel, + getGroupInfo, + }; +} diff --git a/packages/apollo-react/src/canvas/core/TaskTypeRegistry.ts b/packages/apollo-react/src/canvas/core/TaskTypeRegistry.ts new file mode 100644 index 000000000..75d6dd0f0 --- /dev/null +++ b/packages/apollo-react/src/canvas/core/TaskTypeRegistry.ts @@ -0,0 +1,231 @@ +/** + * TaskTypeRegistry - Registry for task type manifests + * + * Manages task type definitions for use within stage nodes. + * Provides validation, lookup, and toolbox integration. + */ + +import type { ListItem } from '../components'; +import type { TaskManifest } from '../schema/task-definition'; + +/** + * Icon resolver function type + */ +export type TaskIconResolver = (iconId: string) => React.ReactElement | undefined; + +/** + * Registry for task type manifests. + * Manages task type definitions using JSON manifests. + */ +export class TaskTypeRegistry { + private taskByType = new Map(); + private tasksByCategory = new Map(); + + /** + * Register task manifests. + * + * @param tasks - Array of task manifests to register + */ + registerTaskManifests(tasks: TaskManifest[]): void { + // Clear existing registrations + this.taskByType.clear(); + this.tasksByCategory.clear(); + + // Build lookup maps + for (const task of tasks) { + this.taskByType.set(task.taskType, task); + + // Group by category for toolbox + const categoryKey = task.category; + const categoryTasks = this.tasksByCategory.get(categoryKey) ?? []; + categoryTasks.push(task); + this.tasksByCategory.set(categoryKey, categoryTasks); + } + + // Sort tasks within each category by sortOrder + for (const [key, categoryTasks] of this.tasksByCategory.entries()) { + categoryTasks.sort((a, b) => a.sortOrder - b.sortOrder); + this.tasksByCategory.set(key, categoryTasks); + } + } + + /** + * Get a task manifest by type. + * + * @param taskType - Task type identifier + * @returns Task manifest or undefined if not found + */ + getManifest(taskType: string): TaskManifest | undefined { + return this.taskByType.get(taskType); + } + + /** + * Get all registered task manifests. + * + * @returns Array of all task manifests + */ + getAllManifests(): TaskManifest[] { + return Array.from(this.taskByType.values()); + } + + /** + * Get all registered task types. + * + * @returns Array of task type identifiers + */ + getAllTaskTypes(): string[] { + return Array.from(this.taskByType.keys()); + } + + /** + * Check if a task type can be added to a stage with given node type. + * + * @param taskType - The task type to check + * @param stageNodeType - The stage's nodeType (e.g., "case-management:Stage") + * @returns true if task can be added to the stage + */ + isTaskAllowedInStage(taskType: string, stageNodeType: string): boolean { + const manifest = this.getManifest(taskType); + if (!manifest) return false; + + // If no allowedStageTypes specified, task is allowed everywhere + if (!manifest.allowedStageTypes || manifest.allowedStageTypes.length === 0) { + return true; + } + + return manifest.allowedStageTypes.includes(stageNodeType); + } + + /** + * Get task options for a stage's toolbox, filtered by allowed stage types. + * + * @param stageNodeType - The stage's nodeType for filtering + * @param iconResolver - Optional function to resolve icon identifiers to React elements + * @returns Array of ListItems for toolbox display + */ + getTaskOptionsForStage( + stageNodeType: string, + iconResolver?: TaskIconResolver + ): ListItem[] { + const options: ListItem[] = []; + + for (const manifest of this.taskByType.values()) { + if (!this.isTaskAllowedInStage(manifest.taskType, stageNodeType)) { + continue; + } + + options.push({ + id: manifest.taskType, + name: manifest.display.label, + description: manifest.display.description, + icon: iconResolver + ? { Component: () => iconResolver(manifest.display.icon) ?? null } + : undefined, + section: manifest.category, + data: { + taskType: manifest.taskType, + defaultProperties: manifest.defaultProperties, + }, + }); + } + + // Sort by section (category), then by sortOrder + return options.sort((a, b) => { + const catA = a.section ?? ''; + const catB = b.section ?? ''; + if (catA !== catB) return catA.localeCompare(catB); + + const manifestA = this.taskByType.get(a.id); + const manifestB = this.taskByType.get(b.id); + return (manifestA?.sortOrder ?? 0) - (manifestB?.sortOrder ?? 0); + }); + } + + /** + * Get tasks grouped by category. + * + * @param stageNodeType - Optional stage node type for filtering + * @returns Map of category to task manifests + */ + getTasksByCategory(stageNodeType?: string): Map { + if (!stageNodeType) { + return this.tasksByCategory; + } + + // Filter by stage type + const filtered = new Map(); + for (const [category, tasks] of this.tasksByCategory.entries()) { + const allowedTasks = tasks.filter((t) => + this.isTaskAllowedInStage(t.taskType, stageNodeType) + ); + if (allowedTasks.length > 0) { + filtered.set(category, allowedTasks); + } + } + return filtered; + } + + /** + * Search tasks by label or tags. + * + * @param query - Search query + * @param stageNodeType - Optional stage node type for filtering + * @returns Array of matching task manifests + */ + searchTasks(query: string, stageNodeType?: string): TaskManifest[] { + const lowerQuery = query.toLowerCase(); + const results: TaskManifest[] = []; + + for (const manifest of this.taskByType.values()) { + // Apply stage type filter if provided + if (stageNodeType && !this.isTaskAllowedInStage(manifest.taskType, stageNodeType)) { + continue; + } + + // Check label + if (manifest.display.label.toLowerCase().includes(lowerQuery)) { + results.push(manifest); + continue; + } + + // Check description + if (manifest.display.description?.toLowerCase().includes(lowerQuery)) { + results.push(manifest); + continue; + } + + // Check tags + if (manifest.tags.some((tag) => tag.toLowerCase().includes(lowerQuery))) { + results.push(manifest); + } + } + + return results.sort((a, b) => a.sortOrder - b.sortOrder); + } + + /** + * Create default task data from a manifest. + * + * @param taskType - Task type identifier + * @returns Default task data or undefined if manifest not found + */ + createDefaultTaskData(taskType: string): Record | undefined { + const manifest = this.getManifest(taskType); + if (!manifest) return undefined; + + return { + taskType: manifest.taskType, + label: manifest.display.label, + icon: manifest.display.icon, + ...(manifest.defaultProperties ?? {}), + }; + } + + /** + * Clear all registrations. + */ + clear(): void { + this.taskByType.clear(); + this.tasksByCategory.clear(); + } +} diff --git a/packages/apollo-react/src/canvas/core/index.ts b/packages/apollo-react/src/canvas/core/index.ts index f07d941da..59b7b3bd4 100644 --- a/packages/apollo-react/src/canvas/core/index.ts +++ b/packages/apollo-react/src/canvas/core/index.ts @@ -2,4 +2,6 @@ export * from './CategoryTree'; export * from './CategoryTreeAdapter'; export * from './NodeRegistryProvider'; export * from './NodeTypeRegistry'; +export * from './TaskTypeRegistry'; export * from './useNodeTypeRegistry'; +export * from './useTaskTypeRegistry'; diff --git a/packages/apollo-react/src/canvas/core/useTaskTypeRegistry.ts b/packages/apollo-react/src/canvas/core/useTaskTypeRegistry.ts new file mode 100644 index 000000000..2c2f33c5e --- /dev/null +++ b/packages/apollo-react/src/canvas/core/useTaskTypeRegistry.ts @@ -0,0 +1,67 @@ +import { createContext, useContext, useMemo } from 'react'; +import type { TaskManifest } from '../schema/task-definition'; +import type { TaskTypeRegistry } from './TaskTypeRegistry'; + +interface TaskRegistryContextValue { + registry: TaskTypeRegistry; +} + +export const TaskRegistryContext = createContext(null); + +/** + * Hook to access the task type registry. + * @throws {Error} If used outside of TaskRegistryProvider + * @returns TaskTypeRegistry instance + */ +export const useTaskTypeRegistry = (): TaskTypeRegistry => { + const context = useContext(TaskRegistryContext); + if (!context) { + throw new Error('useTaskTypeRegistry must be used within a TaskRegistryProvider'); + } + return context.registry; +}; + +/** + * Hook to optionally access the task type registry. + * @returns TaskTypeRegistry instance or null if not available + */ +export const useOptionalTaskTypeRegistry = (): TaskTypeRegistry | null => { + const context = useContext(TaskRegistryContext); + return context?.registry ?? null; +}; + +/** + * Hook to get all registered task manifests. + * @returns Array of all task manifests + */ +export const useTaskManifests = (): TaskManifest[] => { + const registry = useTaskTypeRegistry(); + return useMemo(() => registry.getAllManifests(), [registry]); +}; + +/** + * Hook to get a specific task manifest by type. + * @param taskType - Task type identifier + * @returns Task manifest or undefined if not found + */ +export const useTaskManifest = (taskType: string): TaskManifest | undefined => { + const registry = useTaskTypeRegistry(); + return useMemo(() => registry.getManifest(taskType), [registry, taskType]); +}; + +/** + * Hook to check if a task type is allowed in a specific stage. + * @param taskType - Task type identifier + * @param stageNodeType - Stage node type (e.g., "case-management:Stage") + * @returns true if task is allowed in the stage + */ +export const useIsTaskAllowedInStage = ( + taskType: string, + stageNodeType: string +): boolean => { + const registry = useTaskTypeRegistry(); + return useMemo( + () => registry.isTaskAllowedInStage(taskType, stageNodeType), + [registry, taskType, stageNodeType] + ); +}; diff --git a/packages/apollo-react/src/canvas/hooks/CrossStageDragContext.tsx b/packages/apollo-react/src/canvas/hooks/CrossStageDragContext.tsx new file mode 100644 index 000000000..2a6f7d5fd --- /dev/null +++ b/packages/apollo-react/src/canvas/hooks/CrossStageDragContext.tsx @@ -0,0 +1,21 @@ +/** + * CrossStageDragContext - Shared context for cross-stage drag state + * + * Allows StageNode to access drag state and render drop placeholders + */ + +import { createContext, useContext } from 'react'; +import type { CrossStageDragState } from './useCrossStageTaskDrag'; + +interface CrossStageDragContextValue { + dragState: CrossStageDragState; +} + +const CrossStageDragContext = createContext(null); + +export const CrossStageDragProvider = CrossStageDragContext.Provider; + +export function useCrossStageDragState(): CrossStageDragState | null { + const context = useContext(CrossStageDragContext); + return context?.dragState ?? null; +} diff --git a/packages/apollo-react/src/canvas/hooks/calculateTaskDropPosition.test.ts b/packages/apollo-react/src/canvas/hooks/calculateTaskDropPosition.test.ts new file mode 100644 index 000000000..e9408a24a --- /dev/null +++ b/packages/apollo-react/src/canvas/hooks/calculateTaskDropPosition.test.ts @@ -0,0 +1,883 @@ +import { describe, expect, it } from 'vitest'; +import { calculateDropPosition, convertToGroupPosition } from './calculateTaskDropPosition'; +import { DEFAULT_TASK_POSITION_CONFIG } from '../components/TaskNode/useTaskPositions'; + +const config = DEFAULT_TASK_POSITION_CONFIG; + +describe('calculateTaskDropPosition', () => { + describe('calculateDropPosition', () => { + describe('Y-position bucket calculation', () => { + it('returns index 0 when node center is above first task midpoint', () => { + // taskIds: [[task-1], [task-2], [task-3]] + // Task positions relative to content: 16, 64 (16+36+12), 112 (64+36+12) + const taskIds = [['task-1'], ['task-2'], ['task-3']]; + const stageWidth = 304; + const nodeHeight = 36; + const nodeWidth = 276; // Full width task + + // Node at header height + contentPaddingTop = 56 + 16 = 72 (stage-relative) + // PY = 72 - 56 + 18 = 34 (content-relative center) + // Bottom of index 0 = 16 + 36 = 52, + halfGap (6) = 58 + // PY (34) <= 58, so index = 0 + const result = calculateDropPosition( + 72, // nodeY: header(56) + content padding(16) + nodeHeight, + 14, // nodeX: left padding + nodeWidth, + stageWidth, + taskIds, + 'task-dragged' // not in this stage + ); + + expect(result.index).toBe(0); + }); + + it('returns index 1 when node center is in second bucket', () => { + const taskIds = [['task-1'], ['task-2'], ['task-3']]; + const stageWidth = 304; + const nodeHeight = 36; + const nodeWidth = 276; + + // Place node center in second bucket + // Bottom of index 0 + halfGap = 52 + 6 = 58 + // Bottom of index 1 + halfGap = 52 + 36 + 12 + 6 = 106 + // Put PY at 80 (between 58 and 106) + // nodeY = 80 + 56 - 18 = 118 + const result = calculateDropPosition( + 118, + nodeHeight, + 14, + nodeWidth, + stageWidth, + taskIds, + 'task-dragged' + ); + + expect(result.index).toBe(1); + }); + + it('returns last index when node is below all tasks', () => { + const taskIds = [['task-1'], ['task-2']]; + const stageWidth = 304; + const nodeHeight = 36; + const nodeWidth = 276; + + // Put node far below all tasks + // Bottom of last task (index 1) = 16 + 36 + 12 + 36 = 100 + // Threshold for "after all" = 100 + 6 = 106 + // Put PY at 150 (well above 106) + // nodeY = 150 + 56 - 18 = 188 + const result = calculateDropPosition( + 188, + nodeHeight, + 14, + nodeWidth, + stageWidth, + taskIds, + 'task-dragged' + ); + + expect(result.index).toBe(2); // Insert after last (totalTasks = 2) + }); + + it('returns index N-1 when node is in the second-to-last bucket', () => { + // Test that index N-1 (last task position) is reachable + const taskIds = [['task-1'], ['task-2'], ['task-3']]; + const stageWidth = 304; + const nodeHeight = 36; + const nodeWidth = 276; + + // 3 tasks: indices 0, 1, 2, totalTasks = 3 + // Task positions (content-relative Y): + // Task 0: top=16, bottom=52 + // Task 1: top=64, bottom=100 + // Task 2: top=112, bottom=148 + // halfGap = 6 + // + // Bucket for index 2: bottom(1) + halfGap < PY <= bottom(2) + halfGap + // 100 + 6 = 106 < PY <= 148 + 6 = 154 + // Put PY at 130 (within this range) + // nodeY = 130 + 56 - 18 = 168 + const result = calculateDropPosition( + 168, + nodeHeight, + 14, + nodeWidth, + stageWidth, + taskIds, + 'task-dragged' + ); + + expect(result.index).toBe(2); // Index N-1 (second to last) + }); + + it('returns 0 for empty stage', () => { + const taskIds: string[][] = []; + const result = calculateDropPosition(100, 36, 14, 276, 304, taskIds, 'task-dragged'); + + expect(result.index).toBe(0); + }); + }); + + describe('X-position parallel detection', () => { + it('returns isParallel=false when node center is in left half', () => { + const taskIds = [['task-1'], ['task-2']]; + const stageWidth = 304; + const nodeWidth = 276; + + // nodeX = 14, center = 14 + 138 = 152, which is exactly stageWidth/2 + // Let's put it more to the left + const result = calculateDropPosition( + 100, // nodeY + 36, // nodeHeight + 0, // nodeX - far left + 100, // nodeWidth - narrow + stageWidth, + taskIds, + 'task-dragged' + ); + + // center = 0 + 50 = 50 < 152 (stageWidth/2) + // Sequential task at index 1 can be sequential + expect(result.isParallel).toBe(false); + }); + + it('returns isParallel=false when over sequential tasks even in right half', () => { + // Sequential tasks should NEVER be grouped, regardless of X position + const taskIds = [['task-1'], ['task-2']]; + const stageWidth = 304; + + const result = calculateDropPosition( + 100, // nodeY + 36, // nodeHeight + 200, // nodeX - far right + 100, // nodeWidth + stageWidth, + taskIds, + 'task-dragged' + ); + + // Even though center = 200 + 50 = 250 > 152 (stageWidth/2), + // target is a sequential task, so isParallel must be false + expect(result.isParallel).toBe(false); + }); + + it('forces isParallel=true when in middle of parallel group', () => { + // For middle tasks in parallel group, canBeSequential is false + const taskIds = [['task-1', 'task-2', 'task-3']]; + const stageWidth = 304; + + // Even with node in left half, middle of parallel group stays parallel + const result = calculateDropPosition( + 100, // nodeY - positions at index 1 (middle task) + 36, + 0, // far left + 50, + stageWidth, + taskIds, + 'task-dragged' + ); + + // At index 1 in a 3-task parallel group, taskIndex 1 is not edge + // So canBeSequential = false, isParallel = true regardless of X + expect(result.isParallel).toBe(true); + }); + }); + + describe('dragged task exclusion', () => { + it('excludes dragged task from position calculation', () => { + const taskIds = [['task-1'], ['task-dragged'], ['task-2']]; + const stageWidth = 304; + + // After filtering, taskIds becomes [['task-1'], ['task-2']] + // So total tasks = 2, not 3 + const result = calculateDropPosition( + 200, // nodeY - far down + 36, + 14, + 276, + stageWidth, + taskIds, + 'task-dragged' + ); + + // Should return index 2 (insert after 2 remaining tasks) + expect(result.index).toBe(2); + }); + + it('handles dragged task in parallel group', () => { + const taskIds = [['task-1', 'task-dragged'], ['task-2']]; + const stageWidth = 304; + + // After filtering: [['task-1'], ['task-2']] + const result = calculateDropPosition( + 200, + 36, + 14, + 276, + stageWidth, + taskIds, + 'task-dragged' + ); + + expect(result.index).toBe(2); + }); + }); + }); + + describe('convertToGroupPosition', () => { + it('returns groupIndex 0 for empty taskIds', () => { + const result = convertToGroupPosition(0, false, []); + expect(result).toEqual({ groupIndex: 0, taskIndex: 0 }); + }); + + it('converts sequential index 0 to first group', () => { + const taskIds = [['task-1'], ['task-2']]; + const result = convertToGroupPosition(0, false, taskIds); + expect(result).toEqual({ groupIndex: 0, taskIndex: 0 }); + }); + + it('converts sequential index 1 to second group', () => { + const taskIds = [['task-1'], ['task-2']]; + const result = convertToGroupPosition(1, false, taskIds); + expect(result).toEqual({ groupIndex: 1, taskIndex: 0 }); + }); + + it('converts parallel index to same group', () => { + const taskIds = [['task-1', 'task-2'], ['task-3']]; + const result = convertToGroupPosition(1, true, taskIds); + // index 1 is task-2 in group 0, parallel = insert at that position + expect(result).toEqual({ groupIndex: 0, taskIndex: 1 }); + }); + + it('appends to existing parallel group at end', () => { + const taskIds = [['task-1'], ['task-2', 'task-3']]; + const result = convertToGroupPosition(3, true, taskIds); + // index 3 is beyond end, parallel, last group is parallel (2 tasks) + // So append to last group + expect(result).toEqual({ groupIndex: 1, taskIndex: 2 }); + }); + + it('creates new group at end for sequential', () => { + const taskIds = [['task-1'], ['task-2']]; + const result = convertToGroupPosition(2, false, taskIds); + expect(result).toEqual({ groupIndex: 2, taskIndex: 0 }); + }); + + it('joins previous parallel group even when it appears sequential after filtering', () => { + // Original: [['task-1', 'task-2'], ['task-3']] - task-1 and task-2 are parallel + // Dragging task-1 down + // Filtered: [['task-2'], ['task-3']] - task-2's group now looks sequential! + // When isParallel=true and we're at the start of task-3's group, + // we should join task-2's group because it was ORIGINALLY parallel + const filteredTaskIds = [['task-2'], ['task-3']]; + const originalTaskIds = [['task-1', 'task-2'], ['task-3']]; + + // index 1 is task-3 (first task in second group), isParallel=true + const result = convertToGroupPosition(1, true, filteredTaskIds, undefined, originalTaskIds); + + // Should join previous group (task-2's group at index 0), not task-3's group + expect(result).toEqual({ groupIndex: 0, taskIndex: 1 }); + }); + + it('appends to last group when it was originally parallel', () => { + // Original: [['task-1', 'task-2']] - parallel group + // Dragging task-1 to end + // Filtered: [['task-2']] - appears sequential + const filteredTaskIds = [['task-2']]; + const originalTaskIds = [['task-1', 'task-2']]; + + // index 1 is beyond end, isParallel=true + const result = convertToGroupPosition(1, true, filteredTaskIds, undefined, originalTaskIds); + + // Should append to last group because it was originally parallel + expect(result).toEqual({ groupIndex: 0, taskIndex: 1 }); + }); + + it('does not join previous group when it was not originally parallel', () => { + // Original: [['task-1'], ['task-2'], ['task-3']] - all sequential + // Dragging task-1 down + // Filtered: [['task-2'], ['task-3']] + const filteredTaskIds = [['task-2'], ['task-3']]; + const originalTaskIds = [['task-1'], ['task-2'], ['task-3']]; + + // index 1 is task-3, isParallel=true + // But previous group was NOT originally parallel, so should NOT join it + const result = convertToGroupPosition(1, true, filteredTaskIds, undefined, originalTaskIds); + + // Should insert into current group at task-3's position + expect(result).toEqual({ groupIndex: 1, taskIndex: 0 }); + }); + + it('joins current group when dragging bottom task up from parallel group (parallel group above)', () => { + // Original: [['task-1', 'task-2'], ['task-3', 'task-4']] - two parallel groups + // Dragging task-4 (bottom of second group) up + // Filtered: [['task-1', 'task-2'], ['task-3']] + // task-3's group now looks sequential but was originally parallel + const filteredTaskIds = [['task-1', 'task-2'], ['task-3']]; + const originalTaskIds = [['task-1', 'task-2'], ['task-3', 'task-4']]; + + // index 2 is task-3 (first task in second group), isParallel=true + const result = convertToGroupPosition(2, true, filteredTaskIds, undefined, originalTaskIds); + + // Should join CURRENT group (task-3's group), NOT the previous parallel group + expect(result).toEqual({ groupIndex: 1, taskIndex: 0 }); + }); + + it('joins current group when dragging bottom task up from parallel group (sequential task above)', () => { + // Original: [['task-1'], ['task-2', 'task-3']] - sequential then parallel + // Dragging task-3 (bottom of parallel group) up + // Filtered: [['task-1'], ['task-2']] + // task-2's group now looks sequential but was originally parallel + const filteredTaskIds = [['task-1'], ['task-2']]; + const originalTaskIds = [['task-1'], ['task-2', 'task-3']]; + + // index 1 is task-2 (first task in second group), isParallel=true + const result = convertToGroupPosition(1, true, filteredTaskIds, undefined, originalTaskIds); + + // Should join CURRENT group (task-2's group), NOT affect task-1 + expect(result).toEqual({ groupIndex: 1, taskIndex: 0 }); + }); + }); + + describe('edge cases', () => { + describe('parallel threshold boundary', () => { + it('returns isParallel=false for sequential tasks regardless of X position', () => { + // Sequential tasks should NEVER be grouped + const taskIds = [['task-1'], ['task-2']]; + const stageWidth = 304; + // Center at exactly 152 (stageWidth/2) + const result = calculateDropPosition( + 100, + 36, + 102, // nodeX so center is at 152 + 100, // nodeWidth + stageWidth, + taskIds, + 'task-dragged' + ); + + // Target is sequential, so isParallel must be false + expect(result.isParallel).toBe(false); + }); + + it('returns isParallel=false when node center is 1px left of midpoint', () => { + const taskIds = [['task-1'], ['task-2']]; + const stageWidth = 304; + // Center at 151 (1px left of midpoint) + const result = calculateDropPosition( + 100, + 36, + 101, // nodeX so center is at 151 + 100, + stageWidth, + taskIds, + 'task-dragged' + ); + + // Target is sequential, so isParallel must be false + expect(result.isParallel).toBe(false); + }); + + it('returns isParallel=false for sequential tasks even in right half', () => { + // Sequential tasks should NEVER be grouped, regardless of X position + const taskIds = [['task-1'], ['task-2']]; + const stageWidth = 304; + // Center at 153 (1px right of midpoint) + const result = calculateDropPosition( + 100, + 36, + 103, // nodeX so center is at 153 + 100, + stageWidth, + taskIds, + 'task-dragged' + ); + + // Target is sequential, so isParallel must be false + expect(result.isParallel).toBe(false); + }); + + it('X position threshold works for parallel groups', () => { + // When over a parallel group edge, X position determines parallel vs sequential + const taskIds = [['task-1', 'task-2']]; // Parallel group + const stageWidth = 304; + + // Position at first task (edge of parallel group), right half + const resultRight = calculateDropPosition( + 72, // First task position + 36, + 200, // Right half + 100, + stageWidth, + taskIds, + 'task-dragged' + ); + expect(resultRight.isParallel).toBe(true); // Stays parallel + + // Position at first task (edge of parallel group), left half + const resultLeft = calculateDropPosition( + 72, + 36, + 0, // Left half + 100, + stageWidth, + taskIds, + 'task-dragged' + ); + expect(resultLeft.isParallel).toBe(false); // Can break out to sequential + }); + }); + + describe('parallel group edge detection', () => { + it('allows first task in parallel group to become sequential', () => { + // First task in parallel group can leave (taskIndex === 0) + const taskIds = [['task-1', 'task-2', 'task-3']]; + const stageWidth = 304; + + // Drop at index 0 (first task position), left half + const result = calculateDropPosition( + 72, // nodeY for first position + 36, + 0, // far left + 50, + stageWidth, + taskIds, + 'task-dragged' + ); + + // Index 0, taskIndex 0 in group → can be sequential + expect(result.index).toBe(0); + expect(result.isParallel).toBe(false); + }); + + it('allows last task in parallel group to become sequential', () => { + // Last task in parallel group can leave (taskIndex === group.length - 1) + const taskIds = [['task-1', 'task-2', 'task-3']]; + const stageWidth = 304; + + // For 3-task parallel group: + // - Index 0: top=16, bottom=52 + // - Index 1: top=64, bottom=100 + // - Index 2: top=112, bottom=148 + // Rule 3 triggers when PY > topOfLast - halfGap = 112 - 6 = 106 + // So dropping past index 1's bucket goes to "after all" position + // Let's test bucket 1 instead (between tasks 0 and 1) + // Bucket 1: PY > 52 + 6 = 58 AND PY <= 100 + 6 = 106 + // Put PY at 80 + const result = calculateDropPosition( + 118, // nodeY = 80 + 56 - 18 = 118 + 36, + 0, // far left + 50, + stageWidth, + taskIds, + 'task-dragged' + ); + + // Index 1, taskIndex 1 is middle of 3-task group → NOT edge, so parallel + // Actually this tests the middle case. For edge case, use 2-task group + expect(result.index).toBe(1); + expect(result.isParallel).toBe(true); // Middle cannot be sequential + }); + + it('allows edge task in 2-task parallel group to become sequential', () => { + // In a 2-task parallel group, both tasks are edges (first or last) + const taskIds = [['task-1', 'task-2']]; + const stageWidth = 304; + + // For 2-task parallel group: + // - Index 0: top=16, bottom=52 + // - Index 1: top=64, bottom=100 + // Bucket 1: PY > 52 + 6 = 58 AND PY <= topOfLast - halfGap = 64 - 6 = 58 + // Actually bucket 1 range is very narrow, let's test index 0 + // Bucket 0: PY <= 52 + 6 = 58 + const result = calculateDropPosition( + 72, // nodeY for first position (PY ~= 34) + 36, + 0, // far left + 50, + stageWidth, + taskIds, + 'task-dragged' + ); + + // Index 0, taskIndex 0 (first in 2-task group) → edge, can be sequential + expect(result.index).toBe(0); + expect(result.isParallel).toBe(false); + }); + + it('forces middle task to stay parallel even in left half', () => { + // Middle task cannot become sequential + const taskIds = [['task-1', 'task-2', 'task-3', 'task-4']]; + const stageWidth = 304; + + // Drop at index 1 or 2 (middle positions), left half + const result = calculateDropPosition( + 118, // nodeY for second position area + 36, + 0, // far left + 50, + stageWidth, + taskIds, + 'task-dragged' + ); + + // Index 1, taskIndex 1 (middle in 4-task group) → cannot be sequential + expect(result.index).toBe(1); + expect(result.isParallel).toBe(true); + }); + }); + + describe('multiple parallel groups', () => { + it('handles stage with multiple parallel groups - joins last parallel group from right half', () => { + const taskIds = [ + ['task-1', 'task-2'], // Parallel group at index 0-1 + ['task-3'], // Sequential at index 2 + ['task-4', 'task-5'], // Parallel group at index 3-4 + ]; + const stageWidth = 304; + + // Drop far below to get index beyond end, right half + const result = calculateDropPosition( + 300, // nodeY - very far down + 36, + 200, // right half - should join last parallel group + 100, + stageWidth, + taskIds, + 'task-dragged' + ); + + expect(result.index).toBe(5); // After all 5 tasks + // At end with last group being parallel AND in right half -> joins parallel group + expect(result.isParallel).toBe(true); + }); + + it('handles stage with multiple parallel groups - sequential from left half', () => { + const taskIds = [ + ['task-1', 'task-2'], // Parallel group at index 0-1 + ['task-3'], // Sequential at index 2 + ['task-4', 'task-5'], // Parallel group at index 3-4 + ]; + const stageWidth = 304; + + // Drop far below to get index beyond end, left half + const result = calculateDropPosition( + 300, // nodeY - very far down + 36, + 0, // left half - should be sequential + 100, + stageWidth, + taskIds, + 'task-dragged' + ); + + expect(result.index).toBe(5); // After all 5 tasks + // At end but left half -> sequential (new group) + expect(result.isParallel).toBe(false); + }); + + it('calculates correct index within second parallel group', () => { + const taskIds = [ + ['task-1'], // Index 0 (top=16, bottom=52) + ['task-2', 'task-3'], // Index 1-2 (top=64/112, bottom=100/148) + ]; + const stageWidth = 304; + + // For this structure: + // - Index 0 (task-1): top=16, bottom=52 + // - Index 1 (task-2): top=64, bottom=100 + // - Index 2 (task-3): top=112, bottom=148 + // Rule 3: PY > topOfLast - halfGap = 112 - 6 = 106 → index = 3 + // So bucket 2 doesn't exist, it goes straight to "after all" + // Test bucket 1 instead: 58 < PY <= 106 + const result = calculateDropPosition( + 138, // nodeY such that PY = 100 (in bucket 1) + 36, + 0, // left half + 50, + stageWidth, + taskIds, + 'task-dragged' + ); + + // Index 1 is first in the parallel group (edge), can be sequential + expect(result.index).toBe(1); + expect(result.isParallel).toBe(false); + }); + }); + + describe('single task scenarios', () => { + it('handles single task in stage', () => { + const taskIds = [['task-1']]; + const stageWidth = 304; + + // Drop below the single task + const result = calculateDropPosition( + 200, + 36, + 0, + 50, + stageWidth, + taskIds, + 'task-dragged' + ); + + expect(result.index).toBe(1); + expect(result.isParallel).toBe(false); + }); + + it('handles drop above single task', () => { + const taskIds = [['task-1']]; + const stageWidth = 304; + + // Drop above the single task + const result = calculateDropPosition( + 60, // Very high + 36, + 0, + 50, + stageWidth, + taskIds, + 'task-dragged' + ); + + expect(result.index).toBe(0); + expect(result.isParallel).toBe(false); + }); + }); + + describe('dragged task filtering edge cases', () => { + it('handles dragged task being the only task in a group', () => { + const taskIds = [['task-1'], ['task-dragged'], ['task-2']]; + const stageWidth = 304; + + // After filtering: [['task-1'], ['task-2']] + // Empty group is removed + const result = calculateDropPosition( + 100, + 36, + 0, + 50, + stageWidth, + taskIds, + 'task-dragged' + ); + + expect(result.index).toBeLessThanOrEqual(2); + }); + + it('handles dragged task at start of parallel group', () => { + const taskIds = [['task-dragged', 'task-1', 'task-2']]; + const stageWidth = 304; + + // After filtering: [['task-1', 'task-2']] + // Y=100 with nodeHeight=36 gives PY = 100 - 56 + 18 = 62 + // This falls in bucket for index 1 (within the parallel group) + const result = calculateDropPosition( + 100, + 36, + 200, // right half + 100, + stageWidth, + taskIds, + 'task-dragged' + ); + + // Index 1 is within parallel group, and right half keeps it parallel + expect(result.index).toBe(1); + expect(result.isParallel).toBe(true); + }); + + it('stays parallel when dropping within parallel group', () => { + const taskIds = [['task-dragged', 'task-1', 'task-2']]; + const stageWidth = 304; + + // After filtering: [['task-1', 'task-2']] + // Drop at Y position within the parallel group (index 0) + const result = calculateDropPosition( + 72, // Position within first task bucket + 36, + 200, // right half + 100, + stageWidth, + taskIds, + 'task-dragged' + ); + + // Within parallel group, stays parallel + expect(result.isParallel).toBe(true); + }); + + it('handles dragged task at end of parallel group', () => { + const taskIds = [['task-1', 'task-2', 'task-dragged']]; + const stageWidth = 304; + + // After filtering: [['task-1', 'task-2']] + const result = calculateDropPosition( + 100, + 36, + 0, // left half + 50, + stageWidth, + taskIds, + 'task-dragged' + ); + + // Dropping at position in 2-task parallel group + // First or last can be sequential + expect(result.isParallel).toBe(false); + }); + }); + + describe('rejoining parallel group after filtering', () => { + it('allows rejoining 2-task parallel group that appears sequential after filtering', () => { + // Original: sequential, then 2-task parallel group + // Dragging task-3 makes filtered = [['task-1'], ['task-2']] - appears sequential + // But we should still be able to rejoin the parallel group + const taskIds = [['task-1'], ['task-2', 'task-3']]; + const stageWidth = 304; + + // Drop at end, right half - should join the "now-sequential" group because it was originally parallel + const result = calculateDropPosition( + 200, // nodeY far below (past all filtered tasks) + 36, + 200, // right half + 100, + stageWidth, + taskIds, + 'task-3' + ); + + expect(result.index).toBe(2); // After both filtered tasks (index = totalTasks) + expect(result.isParallel).toBe(true); // Should be able to rejoin + }); + + it('rejoins parallel group when dropping within its position', () => { + // Original: 2-task parallel group + // Dragging task-2 makes filtered = [['task-1']] - single element + const taskIds = [['task-1', 'task-2']]; + const stageWidth = 304; + + // Drop at first task position, right half + const result = calculateDropPosition( + 72, // First task position + 36, + 200, // right half + 100, + stageWidth, + taskIds, + 'task-2' + ); + + expect(result.isParallel).toBe(true); // Can rejoin because original was parallel + }); + + it('stays sequential when original group was sequential', () => { + // Original: two sequential groups + const taskIds = [['task-1'], ['task-2']]; + const stageWidth = 304; + + // Drop at end, right half - should NOT become parallel because original was sequential + const result = calculateDropPosition( + 200, // Far below + 36, + 200, // right half + 100, + stageWidth, + taskIds, + 'task-2' + ); + + // After filtering: [['task-1']], single sequential task + // Original group for task-1 was sequential (single element), so can't join it as parallel + expect(result.isParallel).toBe(false); + }); + + it('joins previous parallel group when dropping between parallel and sequential', () => { + // Original: parallel group followed by sequential task + const taskIds = [['task-1', 'task-2'], ['task-3']]; + const stageWidth = 304; + + // Drop at position of task-3 (between parallel group and sequential), right half + // Task positions in content area: + // task-1: top=16, bottom=52 + // task-2: top=64, bottom=100 + // task-3: top=112, bottom=148 + // Drop with PY around 112 (at task-3's position), right half + const result = calculateDropPosition( + 168, // nodeY = 112 + 56 = 168 for task-3 position + 36, + 200, // right half - should join parallel group + 100, + stageWidth, + taskIds, + 'task-X' // External task + ); + + expect(result.index).toBe(2); // At task-3's position + expect(result.isParallel).toBe(true); // Should join the previous parallel group + }); + + it('stays sequential between parallel and sequential when in left half', () => { + // Original: parallel group followed by sequential task + const taskIds = [['task-1', 'task-2'], ['task-3']]; + const stageWidth = 304; + + // Drop at position of task-3 (between parallel group and sequential), left half + const result = calculateDropPosition( + 168, // nodeY for task-3 position + 36, + 0, // left half - should stay sequential + 100, + stageWidth, + taskIds, + 'task-X' // External task + ); + + expect(result.index).toBe(2); + expect(result.isParallel).toBe(false); // Should be sequential in left half + }); + }); + + describe('convertToGroupPosition edge cases', () => { + it('handles inserting parallel at start of first group', () => { + const taskIds = [['task-1', 'task-2'], ['task-3']]; + const result = convertToGroupPosition(0, true, taskIds); + expect(result).toEqual({ groupIndex: 0, taskIndex: 0 }); + }); + + it('joins previous parallel group when at start of sequential group', () => { + // At start of sequential group that follows parallel, isParallel=true + // Should join the previous parallel group + const taskIds = [['task-1', 'task-2'], ['task-3']]; + const result = convertToGroupPosition(2, true, taskIds); + // Index 2 is task-3, at groupIndex=1, taskIndex=0 + // Since isParallel=true and previous group is parallel, join it at the end + expect(result).toEqual({ groupIndex: 0, taskIndex: 2 }); + }); + + it('handles inserting sequential between parallel groups', () => { + const taskIds = [['task-1', 'task-2'], ['task-3', 'task-4']]; + const result = convertToGroupPosition(2, false, taskIds); + // Index 2 is start of second group, sequential inserts new group + expect(result).toEqual({ groupIndex: 1, taskIndex: 0 }); + }); + + it('handles parallel insert at end when last group is sequential', () => { + const taskIds = [['task-1'], ['task-2']]; + const result = convertToGroupPosition(2, true, taskIds); + // Beyond end, parallel, but last group has only 1 task + // So create new group at end + expect(result).toEqual({ groupIndex: 2, taskIndex: 0 }); + }); + + it('handles large index values gracefully', () => { + const taskIds = [['task-1'], ['task-2']]; + const result = convertToGroupPosition(100, false, taskIds); + expect(result).toEqual({ groupIndex: 2, taskIndex: 0 }); + }); + }); + }); +}); diff --git a/packages/apollo-react/src/canvas/hooks/calculateTaskDropPosition.ts b/packages/apollo-react/src/canvas/hooks/calculateTaskDropPosition.ts new file mode 100644 index 000000000..0f97e982f --- /dev/null +++ b/packages/apollo-react/src/canvas/hooks/calculateTaskDropPosition.ts @@ -0,0 +1,398 @@ +/** + * Calculate drop position for task drag operations + * + * Simplified position calculation using bucket-based approach: + * - Y position determined by which "bucket" the dragged task falls into + * - X position determines parallel vs sequential based on stage midpoint + */ + +import { DEFAULT_TASK_POSITION_CONFIG } from '../components/TaskNode/useTaskPositions'; + +export interface DropPosition { + /** Flat index (0, 1, 2, ...) for insertion position */ + index: number; + /** Whether dropping as parallel (depth=1) or sequential (depth=0) */ + isParallel: boolean; + /** Dragged task's Y center relative to content area (for group boundary resolution) */ + draggedYCenter: number; +} + +/** + * Get the bottom Y position of a task at a given flat index (relative to content area) + */ +function getTaskBottom(flatIndex: number, taskIds: string[][]): number { + const config = DEFAULT_TASK_POSITION_CONFIG; + let currentY = config.contentPaddingTop; + let currentIndex = 0; + + for (const group of taskIds) { + for (let i = 0; i < group.length; i++) { + if (currentIndex === flatIndex) { + return currentY + config.taskHeight; + } + currentY += config.taskHeight; + if (i < group.length - 1) { + currentY += config.taskGap; + } + currentIndex++; + } + currentY += config.taskGap; + } + + return currentY; +} + +/** + * Get the top Y position of a task at a given flat index (relative to content area) + */ +function getTaskTop(flatIndex: number, taskIds: string[][]): number { + const config = DEFAULT_TASK_POSITION_CONFIG; + let currentY = config.contentPaddingTop; + let currentIndex = 0; + + for (const group of taskIds) { + for (let i = 0; i < group.length; i++) { + if (currentIndex === flatIndex) { + return currentY; + } + currentY += config.taskHeight; + if (i < group.length - 1) { + currentY += config.taskGap; + } + currentIndex++; + } + currentY += config.taskGap; + } + + return currentY; +} + +/** + * Get total number of tasks (flat count) + */ +function getTotalTaskCount(taskIds: string[][]): number { + return taskIds.reduce((sum, group) => sum + group.length, 0); +} + +/** + * Check if a flat index is within an existing parallel group (group.length > 1) + * Returns false for sequential groups and for positions at/beyond the end + */ +function isIndexInParallelGroup(flatIndex: number, taskIds: string[][]): boolean { + let currentIndex = 0; + + for (const group of taskIds) { + for (let taskIndex = 0; taskIndex < group.length; taskIndex++) { + if (currentIndex === flatIndex) { + return group.length > 1; + } + currentIndex++; + } + } + + // Out of bounds - not in any parallel group + return false; +} + +/** + * Check if a task at given flat index is at the edge of a parallel group + * (first or last position in a parallel group) + */ +function isAtParallelGroupEdge(flatIndex: number, taskIds: string[][]): boolean { + let currentIndex = 0; + + for (const group of taskIds) { + for (let taskIndex = 0; taskIndex < group.length; taskIndex++) { + if (currentIndex === flatIndex) { + if (group.length <= 1) return false; + return taskIndex === 0 || taskIndex === group.length - 1; + } + currentIndex++; + } + } + + return false; +} + +/** + * Calculate drop position using simplified bucket-based approach + * + * @param nodeY - The dragged task node's Y position relative to stage (absolute from stage top) + * @param nodeHeight - The height of the dragged task node + * @param nodeX - The dragged task node's X position relative to stage + * @param nodeWidth - The width of the dragged task node + * @param stageWidth - The width of the target stage + * @param taskIds - 2D array of task IDs in the target stage + * @param draggedTaskId - ID of the task being dragged (to exclude from calculations) + * @returns DropPosition with index and isParallel + */ +export function calculateDropPosition( + nodeY: number, + nodeHeight: number, + nodeX: number, + nodeWidth: number, + stageWidth: number, + taskIds: string[][], + draggedTaskId: string +): DropPosition { + const config = DEFAULT_TASK_POSITION_CONFIG; + const halfTaskGap = config.taskGap / 2; + + // Filter out the dragged task from taskIds for position calculation + const filteredTaskIds = taskIds + .map((group) => group.filter((id) => id !== draggedTaskId)) + .filter((group) => group.length > 0); + + const totalTasks = getTotalTaskCount(filteredTaskIds); + + // Calculate PY: center point of dragged node relative to CONTENT area + // nodeY is relative to stage, subtract header to get content-relative + const PY = nodeY - config.headerHeight + nodeHeight / 2; + + // Calculate PX: center point of dragged node + const PX = nodeX + nodeWidth / 2; + + // Calculate index using bucket rules + let index = 0; + + if (totalTasks === 0) { + // Empty stage, drop at index 0 + index = 0; + } else { + // Rule 1: PY <= bottom of index 0 + 1/2 task gap → index 0 + const bottomOfFirst = getTaskBottom(0, filteredTaskIds); + if (PY <= bottomOfFirst + halfTaskGap) { + index = 0; + } else { + // Rule 3: PY > bottom of last task + 1/2 task gap → insert after all tasks + const lastIndex = totalTasks - 1; + const bottomOfLast = getTaskBottom(lastIndex, filteredTaskIds); + if (PY > bottomOfLast + halfTaskGap) { + index = totalTasks; // Insert after last task + } else { + // Rule 2: Find the bucket N where: + // bottom(N-1) + halfGap < PY <= bottom(N) + halfGap + for (let n = 1; n < totalTasks; n++) { + const bottomOfPrev = getTaskBottom(n - 1, filteredTaskIds); + const bottomOfCurrent = getTaskBottom(n, filteredTaskIds); + if (PY > bottomOfPrev + halfTaskGap && PY <= bottomOfCurrent + halfTaskGap) { + index = n; + break; + } + } + } + } + } + + // Determine isParallel based on whether target position is in a parallel group + // RULE: Never group with sequential tasks - only allow parallel if target is already in a parallel group + // NOTE: We check ORIGINAL taskIds to handle the case where removing dragged task makes a parallel group appear sequential + const inParallelGroup = isIndexInParallelGroup(index, filteredTaskIds); + const isLeftHalf = PX < stageWidth / 2; + + // Helper: check if a group in ORIGINAL taskIds was parallel (before filtering) + const wasOriginalGroupParallel = (filteredGroupIndex: number): boolean => { + if (filteredGroupIndex < 0 || filteredGroupIndex >= filteredTaskIds.length) return false; + const filteredGroup = filteredTaskIds[filteredGroupIndex]; + if (!filteredGroup || filteredGroup.length === 0) return false; + // Find this group in original taskIds by checking if any task from filteredGroup is in an original parallel group + const firstTaskId = filteredGroup[0]; + for (const originalGroup of taskIds) { + if (originalGroup.includes(firstTaskId!)) { + return originalGroup.length > 1; + } + } + return false; + }; + + // Helper: find which group index and task index within group for a flat index + const getGroupAndTaskIndex = (flatIndex: number): { groupIndex: number; taskIndex: number } | null => { + let currentIndex = 0; + for (let gi = 0; gi < filteredTaskIds.length; gi++) { + const group = filteredTaskIds[gi]; + if (!group) continue; + if (currentIndex + group.length > flatIndex) { + return { groupIndex: gi, taskIndex: flatIndex - currentIndex }; + } + currentIndex += group.length; + } + return null; + }; + + let isParallel = false; + if (inParallelGroup) { + // Target is in a parallel group - use X position to determine if staying parallel or breaking out + const atEdge = isAtParallelGroupEdge(index, filteredTaskIds); + // Can only break out to sequential if at edge AND on left half + isParallel = !(atEdge && isLeftHalf); + } else if (index >= totalTasks && filteredTaskIds.length > 0) { + // Special case: dropping at end - can join last group if it was originally parallel and X is in right half + const lastFilteredGroup = filteredTaskIds[filteredTaskIds.length - 1]; + const lastGroupWasParallel = wasOriginalGroupParallel(filteredTaskIds.length - 1); + if (lastFilteredGroup && lastGroupWasParallel && !isLeftHalf) { + isParallel = true; + } + } else if (!inParallelGroup && index < totalTasks) { + // Not in a parallel group - check two cases: + // 1. The current position's group was originally parallel (dragged task removed from it) + // 2. We're at the START of a sequential group and the PREVIOUS group is parallel + const posInfo = getGroupAndTaskIndex(index); + if (posInfo) { + const { groupIndex, taskIndex } = posInfo; + + // Case 1: Current group was originally parallel + if (wasOriginalGroupParallel(groupIndex) && !isLeftHalf) { + isParallel = true; + } + // Case 2: At start of a sequential group, previous group is parallel + else if (taskIndex === 0 && groupIndex > 0) { + const currentGroup = filteredTaskIds[groupIndex]; + const prevGroupWasParallel = wasOriginalGroupParallel(groupIndex - 1); + // Only join previous parallel group if current group is sequential (single element) and X is in right half + if (currentGroup && currentGroup.length === 1 && prevGroupWasParallel && !isLeftHalf) { + isParallel = true; + } + } + } + } + // If none of the above conditions are met, isParallel stays false + + return { index, isParallel, draggedYCenter: PY }; +} + +/** + * Get the Y center of a task position at a given flat index (relative to content area) + */ +function getTaskYCenter(flatIndex: number, taskIds: string[][]): number { + const top = getTaskTop(flatIndex, taskIds); + return top + DEFAULT_TASK_POSITION_CONFIG.taskHeight / 2; +} + +/** + * Get info about which group a flat index falls into + */ +function getGroupInfoAtIndex( + flatIndex: number, + taskIds: string[][] +): { groupIndex: number; taskIndex: number; isFirstInGroup: boolean } | null { + let currentIndex = 0; + + for (let groupIndex = 0; groupIndex < taskIds.length; groupIndex++) { + const group = taskIds[groupIndex]; + if (!group) continue; + + for (let taskIndex = 0; taskIndex < group.length; taskIndex++) { + if (currentIndex === flatIndex) { + return { groupIndex, taskIndex, isFirstInGroup: taskIndex === 0 }; + } + currentIndex++; + } + } + + return null; +} + +/** + * Convert flat index and isParallel to groupIndex and taskIndex + * Used to insert the task into the correct position in taskIds + * + * @param index - Flat index for insertion + * @param isParallel - Whether to insert as parallel + * @param taskIds - Current task structure (with dragged task already filtered out) + * @param draggedYCenter - Dragged task's Y center for group boundary resolution + * @param originalTaskIds - Original task structure before filtering (used to detect originally-parallel groups) + */ +export function convertToGroupPosition( + index: number, + isParallel: boolean, + taskIds: string[][], + draggedYCenter?: number, + originalTaskIds?: string[][] +): { groupIndex: number; taskIndex: number } { + if (taskIds.length === 0) { + return { groupIndex: 0, taskIndex: 0 }; + } + + // Helper to check if a group in filtered taskIds was originally parallel + const wasOriginallyParallel = (filteredGroupIndex: number): boolean => { + if (!originalTaskIds || filteredGroupIndex < 0 || filteredGroupIndex >= taskIds.length) { + return false; + } + const filteredGroup = taskIds[filteredGroupIndex]; + if (!filteredGroup || filteredGroup.length === 0) return false; + // If the filtered group already has > 1, it's parallel + if (filteredGroup.length > 1) return true; + // Otherwise check if any task from this group was in an original parallel group + const firstTaskId = filteredGroup[0]; + for (const originalGroup of originalTaskIds) { + if (originalGroup.includes(firstTaskId!)) { + return originalGroup.length > 1; + } + } + return false; + }; + + const groupInfo = getGroupInfoAtIndex(index, taskIds); + + if (groupInfo) { + const { groupIndex, taskIndex, isFirstInGroup } = groupInfo; + + if (isParallel) { + // Inserting as parallel + const currentGroup = taskIds[groupIndex]; + const currentGroupIsSequential = currentGroup && currentGroup.length === 1; + const currentGroupWasParallel = wasOriginallyParallel(groupIndex); + + // If current group was originally parallel (we removed a task from it), + // always join the current group - don't look at previous group + if (currentGroupIsSequential && currentGroupWasParallel) { + // Join current group (which was originally parallel) + return { groupIndex, taskIndex }; + } + + // If at first position of a truly SEQUENTIAL group (not originally parallel) + // that follows a parallel group, join the previous parallel group + if (isFirstInGroup && groupIndex > 0 && currentGroupIsSequential && !currentGroupWasParallel) { + const prevGroup = taskIds[groupIndex - 1]; + const prevGroupWasParallel = wasOriginallyParallel(groupIndex - 1); + if (prevGroup && (prevGroup.length > 1 || prevGroupWasParallel)) { + // Join the previous parallel group at the end + return { groupIndex: groupIndex - 1, taskIndex: prevGroup.length }; + } + } + + // If at first position in a parallel group, use Y position to decide between + // joining previous parallel group or current group + if (isFirstInGroup && groupIndex > 0 && !currentGroupIsSequential && draggedYCenter !== undefined) { + const prevGroup = taskIds[groupIndex - 1]; + const prevGroupWasParallel = wasOriginallyParallel(groupIndex - 1); + if (prevGroup && (prevGroup.length > 1 || prevGroupWasParallel)) { + const placeholderYCenter = getTaskYCenter(index, taskIds); + // If dragged task is above the placeholder position, prefer previous group + if (draggedYCenter < placeholderYCenter) { + return { groupIndex: groupIndex - 1, taskIndex: prevGroup.length }; + } + } + } + + // Insert into current group at this position + return { groupIndex, taskIndex }; + } else { + // Insert as new sequential group before this one + return { groupIndex, taskIndex: 0 }; + } + } + + // Index is at or beyond the end - insert at end + if (isParallel && taskIds.length > 0) { + // Try to append to last group if it's already parallel (check original too) + const lastGroup = taskIds[taskIds.length - 1]; + const lastGroupWasParallel = wasOriginallyParallel(taskIds.length - 1); + if (lastGroup && (lastGroup.length > 1 || lastGroupWasParallel)) { + return { groupIndex: taskIds.length - 1, taskIndex: lastGroup.length }; + } + } + + // Add as new group at the end + return { groupIndex: taskIds.length, taskIndex: 0 }; +} diff --git a/packages/apollo-react/src/canvas/hooks/index.ts b/packages/apollo-react/src/canvas/hooks/index.ts index b40ed1a22..377a1c10d 100644 --- a/packages/apollo-react/src/canvas/hooks/index.ts +++ b/packages/apollo-react/src/canvas/hooks/index.ts @@ -1,8 +1,11 @@ +export * from './CrossStageDragContext'; export * from './ExecutionStatusContext'; export * from './ToolbarActionContext'; export * from './useAddNodeOnConnectEnd'; export * from './useCanvasEvents'; +export * from './useCrossStageTaskDrag'; export * from './useEdgePath'; +export * from './useTaskCopyPaste'; export * from './useExportCanvas'; export * from './useNavigationState'; export * from './usePreviewNode'; diff --git a/packages/apollo-react/src/canvas/hooks/useCrossStageTaskDrag.ts b/packages/apollo-react/src/canvas/hooks/useCrossStageTaskDrag.ts new file mode 100644 index 000000000..0b9131f3e --- /dev/null +++ b/packages/apollo-react/src/canvas/hooks/useCrossStageTaskDrag.ts @@ -0,0 +1,597 @@ +/** + * useCrossStageTaskDrag - Hook for handling task drag between stages using React Flow + * + * This hook enables dragging tasks between different stage nodes. + * Uses the dragged node's position to determine target stage and drop position. + * + * Key features: + * - Uses node position (not mouse) to determine target stage + * - Simplified bucket-based drop position calculation + * - Shows placeholder node at drop location + * - Supports copy mode with modifier key (Alt/Cmd) + */ + +import { type Node, useReactFlow } from '@xyflow/react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + insertTaskAtPosition, + removeTaskFromTaskIds, +} from '../components/TaskNode/taskReorderUtils'; +import { + calculateTaskPositions, + DEFAULT_TASK_POSITION_CONFIG, +} from '../components/TaskNode/useTaskPositions'; +import { calculateDropPosition, convertToGroupPosition } from './calculateTaskDropPosition'; + +/** + * Type for React Flow node drag handler + */ +type OnNodeDrag = (event: React.MouseEvent, node: Node, nodes: Node[]) => void; + +/** + * Information about the current drag operation + */ +export interface CrossStageDragState { + /** Whether a cross-stage drag is in progress */ + isDragging: boolean; + /** The task ID being dragged */ + taskId: string | null; + /** The source stage ID */ + sourceStageId: string | null; + /** The target stage ID (may be same as source during drag) */ + targetStageId: string | null; + /** Whether copy mode is active (Alt/Cmd key held) */ + isCopyMode: boolean; + /** Calculated drop position in target stage */ + dropPosition: DropPosition | null; +} + +/** + * Position where a task would be dropped + */ +export interface DropPosition { + /** Group index (outer array) */ + groupIndex: number; + /** Task index within group */ + taskIndex: number; + /** Whether dropping into a parallel group */ + isParallel: boolean; +} + +/** + * Parameters for task move/copy operations + */ +export interface TaskMoveParams { + /** Task ID being moved */ + taskId: string; + /** Source stage ID */ + sourceStageId: string; + /** Target stage ID */ + targetStageId: string; + /** Position in target stage */ + position: DropPosition; +} + +export interface TaskCopyParams extends TaskMoveParams { + /** New ID for the copied task */ + newTaskId: string; +} + +/** + * Parameters for same-stage reorder operation + */ +export interface TaskReorderParams { + /** Task ID being reordered */ + taskId: string; + /** Stage ID */ + stageId: string; + /** New position in stage */ + position: DropPosition; +} + +/** + * Callbacks for cross-stage task operations + */ +export interface CrossStageTaskCallbacks { + /** Called when a task is moved between stages */ + onTaskMove?: (params: TaskMoveParams) => void; + /** Called when a task is copied between stages */ + onTaskCopy?: (params: TaskCopyParams) => void; + /** Called when a task is reordered within the same stage */ + onTaskReorder?: (params: TaskReorderParams) => void; +} + +/** + * Check if a point is within a stage's bounds + */ +function isPointInStage( + x: number, + y: number, + stageX: number, + stageY: number, + stageWidth: number, + stageHeight: number +): boolean { + return x >= stageX && x <= stageX + stageWidth && y >= stageY && y <= stageY + stageHeight; +} + +/** + * Hook for handling cross-stage task dragging + * Uses node position for stage detection and simplified bucket-based drop calculation + */ +export function useCrossStageTaskDrag(callbacks: CrossStageTaskCallbacks = {}) { + const { setNodes, getNodes } = useReactFlow(); + const { onTaskMove, onTaskCopy, onTaskReorder } = callbacks; + + const [dragState, setDragState] = useState({ + isDragging: false, + taskId: null, + sourceStageId: null, + targetStageId: null, + isCopyMode: false, + dropPosition: null, + }); + + // Track modifier key state + const isCopyModeRef = useRef(false); + + // Track drag state synchronously using refs (React state updates are async) + // This ensures onNodeDragStop can access accurate drag info even if state hasn't updated yet + const isDraggingRef = useRef(false); + const draggedTaskIdRef = useRef(null); + const sourceStageIdRef = useRef(null); + + // Track the current drop position synchronously (updated in onNodeDrag, read in onNodeDragStop) + // This avoids stale closure issues where dragState might not be updated yet + const currentDropPositionRef = useRef<{ + groupIndex: number; + taskIndex: number; + isParallel: boolean; + } | null>(null); + const currentTargetStageRef = useRef(null); + + // Track the last calculated drop position to avoid unnecessary updates in useEffect + const lastDropPositionRef = useRef<{ + groupIndex: number; + taskIndex: number; + isParallel: boolean; + targetStageId: string; + } | null>(null); + + // Update placeholder and task positions when drag state changes + useEffect(() => { + if ( + !dragState.isDragging || + !dragState.dropPosition || + !dragState.taskId || + !dragState.targetStageId + ) { + lastDropPositionRef.current = null; + return; + } + + const { groupIndex, taskIndex, isParallel } = dragState.dropPosition; + + // Skip if position hasn't changed (including isParallel for width updates) + const lastPos = lastDropPositionRef.current; + if ( + lastPos && + lastPos.groupIndex === groupIndex && + lastPos.taskIndex === taskIndex && + lastPos.isParallel === isParallel && + lastPos.targetStageId === dragState.targetStageId + ) { + return; + } + + lastDropPositionRef.current = { + groupIndex, + taskIndex, + isParallel, + targetStageId: dragState.targetStageId, + }; + + // Get target stage node + const allNodes = getNodes(); + const targetStage = allNodes.find((n) => n.id === dragState.targetStageId); + if (!targetStage) return; + + // Get taskIds from target stage + const targetStageData = targetStage.data as Record; + const stageDetails = targetStageData?.stageDetails as Record | undefined; + const taskIds = (stageDetails?.taskIds as string[][]) || []; + + // Remove dragged task to get filtered structure (same as drop logic) + const filteredTaskIds = removeTaskFromTaskIds(taskIds, dragState.taskId); + + // Insert placeholder at calculated position using the SAME utility as drop + // This ensures placeholder and drop behavior are always identical + const placeholderId = '__placeholder__'; + const taskIdsWithPlaceholder = insertTaskAtPosition(filteredTaskIds, placeholderId, { + groupIndex, + taskIndex, + isParallel, + }); + + // Get stage width + const stageWidth = (targetStage.style?.width as number) || 304; + + // Recalculate positions for all tasks and add placeholder node + setNodes((currentNodes) => { + const positions = calculateTaskPositions(taskIdsWithPlaceholder, stageWidth, currentNodes); + + // Helper function to get original positions for a stage (without placeholder) + const getOriginalPositions = (stageId: string) => { + const stage = currentNodes.find((s) => s.id === stageId); + if (!stage) return null; + const stageData = stage.data as Record; + const details = stageData?.stageDetails as Record | undefined; + const stageTaskIds = (details?.taskIds as string[][]) || []; + // Filter out dragged task if it's from this stage + const filteredIds = dragState.taskId + ? removeTaskFromTaskIds(stageTaskIds, dragState.taskId) + : stageTaskIds; + const width = (stage.style?.width as number) || 304; + return calculateTaskPositions(filteredIds, width, currentNodes); + }; + + // Update existing task positions (except the dragged task) + let updatedNodes = currentNodes.map((n) => { + // Update tasks in target stage (except dragged task) + if ( + n.type === 'task' && + n.id !== dragState.taskId && + n.parentId === dragState.targetStageId + ) { + const pos = positions.get(n.id); + if (pos) { + return { + ...n, + position: { x: pos.x, y: pos.y }, + }; + } + } + // Also update tasks in source stage if different from target + if ( + n.type === 'task' && + n.id !== dragState.taskId && + dragState.sourceStageId && + dragState.sourceStageId !== dragState.targetStageId && + n.parentId === dragState.sourceStageId + ) { + const sourcePositions = getOriginalPositions(dragState.sourceStageId); + if (sourcePositions) { + const pos = sourcePositions.get(n.id); + if (pos) { + return { + ...n, + position: { x: pos.x, y: pos.y }, + }; + } + } + } + // Reset tasks in any other stage (not target, not source) to their original positions + // This handles the case where we drag into a stage and then out of it + if ( + n.type === 'task' && + n.id !== dragState.taskId && + n.parentId !== dragState.targetStageId && + n.parentId !== dragState.sourceStageId + ) { + const otherPositions = getOriginalPositions(n.parentId!); + if (otherPositions) { + const pos = otherPositions.get(n.id); + if (pos) { + return { + ...n, + position: { x: pos.x, y: pos.y }, + }; + } + } + } + return n; + }); + + // Remove any existing placeholder + updatedNodes = updatedNodes.filter((n) => n.id !== '__placeholder__'); + + // Add placeholder node at drop position + const placeholderPos = positions.get(placeholderId); + if (placeholderPos && dragState.targetStageId) { + updatedNodes.push({ + id: '__placeholder__', + type: 'placeholder' as any, + parentId: dragState.targetStageId, + extent: 'parent' as any, + position: { x: placeholderPos.x, y: placeholderPos.y }, + width: placeholderPos.width, + data: { isParallel }, + draggable: false, + selectable: false, + focusable: false, + }); + } + + return updatedNodes; + }); + }, [dragState, setNodes, getNodes]); + + // Handle modifier key changes during drag + const handleKeyDown = useCallback((e: KeyboardEvent) => { + if (e.altKey || e.metaKey) { + isCopyModeRef.current = true; + setDragState((prev) => ({ ...prev, isCopyMode: true })); + } + }, []); + + const handleKeyUp = useCallback((e: KeyboardEvent) => { + if (!e.altKey && !e.metaKey) { + isCopyModeRef.current = false; + setDragState((prev) => ({ ...prev, isCopyMode: false })); + } + }, []); + + /** + * Handler for when a node drag starts + */ + const onNodeDragStart: OnNodeDrag = useCallback( + (_event, node, _nodes) => { + // Only handle task nodes + if (node.type !== 'task') return; + + // Get the parent stage ID + const sourceStageId = node.parentId; + if (!sourceStageId) return; + + // Set refs synchronously for immediate access in onNodeDragStop + isDraggingRef.current = true; + draggedTaskIdRef.current = node.id; + sourceStageIdRef.current = sourceStageId; + currentTargetStageRef.current = sourceStageId; + + setDragState({ + isDragging: true, + taskId: node.id, + sourceStageId, + targetStageId: sourceStageId, + isCopyMode: isCopyModeRef.current, + dropPosition: null, + }); + + // Add keyboard listeners for copy mode + window.addEventListener('keydown', handleKeyDown); + window.addEventListener('keyup', handleKeyUp); + }, + [handleKeyDown, handleKeyUp] + ); + + /** + * Handler for during node drag + * Uses node position to determine target stage and drop position + */ + const onNodeDrag: OnNodeDrag = useCallback( + (_event, node, _nodes) => { + if (node.type !== 'task' || !dragState.isDragging) return; + + const allNodes = getNodes(); + + // Get node dimensions + const nodeWidth = (node.width as number) || DEFAULT_TASK_POSITION_CONFIG.taskHeight; + const nodeHeight = + (node.measured?.height as number) || DEFAULT_TASK_POSITION_CONFIG.taskHeight; + + // Calculate node center point in flow coordinates + // Node position is relative to parent, so we need absolute position + const parentStage = allNodes.find((n) => n.id === node.parentId); + const absoluteX = (parentStage?.position.x || 0) + node.position.x; + const absoluteY = (parentStage?.position.y || 0) + node.position.y; + const nodeCenterX = absoluteX + nodeWidth / 2; + const nodeCenterY = absoluteY + nodeHeight / 2; + + // Find which stage contains the node center + const stageNodes = allNodes.filter((n) => { + const nodeType = (n.data as Record)?.nodeType; + return ( + n.type === 'stage' || + n.type === 'stageV2' || + (typeof nodeType === 'string' && nodeType.includes('Stage')) + ); + }); + + let targetStage: Node | undefined; + for (const stage of stageNodes) { + const stageWidth = (stage.style?.width as number) || (stage.width as number) || 304; + const stageHeight = + (stage.style?.height as number) || (stage.measured?.height as number) || 200; + + if ( + isPointInStage( + nodeCenterX, + nodeCenterY, + stage.position.x, + stage.position.y, + stageWidth, + stageHeight + ) + ) { + targetStage = stage; + break; + } + } + + if (targetStage) { + const targetStageData = targetStage.data as Record; + const stageDetails = targetStageData?.stageDetails as Record | undefined; + const taskIds = (stageDetails?.taskIds as string[][]) || []; + const stageWidth = (targetStage.style?.width as number) || 304; + + // Calculate node position relative to target stage + const relativeX = absoluteX - targetStage.position.x; + const relativeY = absoluteY - targetStage.position.y; + + // Calculate drop position using simplified bucket approach + const dropPos = calculateDropPosition( + relativeY, + nodeHeight, + relativeX, + nodeWidth, + stageWidth, + taskIds, + node.id + ); + + // Convert flat index to group/task indices + const filteredTaskIds = taskIds + .map((g) => g.filter((id) => id !== node.id)) + .filter((g) => g.length > 0); + const { groupIndex, taskIndex } = convertToGroupPosition( + dropPos.index, + dropPos.isParallel, + filteredTaskIds, + dropPos.draggedYCenter, + taskIds // Pass original taskIds to detect originally-parallel groups + ); + + // Update refs synchronously for use in onNodeDragStop + // This avoids stale closure issues with React state + currentDropPositionRef.current = { + groupIndex, + taskIndex, + isParallel: dropPos.isParallel, + }; + currentTargetStageRef.current = targetStage!.id; + + setDragState((prev) => ({ + ...prev, + targetStageId: targetStage!.id, + dropPosition: { + groupIndex, + taskIndex, + isParallel: dropPos.isParallel, + }, + })); + } + // When no stage is found, keep the previous valid position + // This keeps the placeholder visible at its last valid location + }, + [dragState.isDragging, getNodes] + ); + + /** + * Handler for when node drag ends + * Replaces placeholder with the dragged node + */ + const onNodeDragStop: OnNodeDrag = useCallback( + (_event, node, _nodes) => { + // Use refs for drag state since React state updates are async + // This ensures we handle the drag stop even if state hasn't updated yet + if (node.type !== 'task' || !isDraggingRef.current) return; + + // Remove keyboard listeners + window.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('keyup', handleKeyUp); + + // Use refs for all drag info (they're set synchronously in onNodeDragStart) + const taskId = draggedTaskIdRef.current; + const sourceStageId = sourceStageIdRef.current; + const isCopyMode = isCopyModeRef.current; + const dropPosition = currentDropPositionRef.current; + const targetStageId = currentTargetStageRef.current; + + if (taskId && sourceStageId && targetStageId && dropPosition) { + const isCrossStage = sourceStageId !== targetStageId; + + if (isCrossStage) { + if (isCopyMode) { + // Copy operation + const newTaskId = `task-${crypto.randomUUID()}`; + onTaskCopy?.({ + taskId, + newTaskId, + sourceStageId, + targetStageId, + position: dropPosition, + }); + } else { + // Move operation + onTaskMove?.({ + taskId, + sourceStageId, + targetStageId, + position: dropPosition, + }); + } + } else { + // Same-stage reorder + onTaskReorder?.({ + taskId, + stageId: sourceStageId, + position: dropPosition, + }); + } + } + + // Clear all refs + isDraggingRef.current = false; + draggedTaskIdRef.current = null; + sourceStageIdRef.current = null; + currentDropPositionRef.current = null; + currentTargetStageRef.current = null; + + // Remove placeholder node and reset dragged node position + // React Flow directly modifies node position during drag, so we need to reset it + setNodes((nodes) => { + const filteredNodes = nodes.filter((n) => n.id !== '__placeholder__'); + + // Find the dragged node and its parent stage to recalculate position + const draggedNode = filteredNodes.find((n) => n.id === taskId); + if (!draggedNode || !draggedNode.parentId) return filteredNodes; + + const parentStage = filteredNodes.find((n) => n.id === draggedNode.parentId); + if (!parentStage) return filteredNodes; + + // Get taskIds from stage to recalculate positions + const stageData = parentStage.data as Record; + const stageDetails = stageData?.stageDetails as Record | undefined; + const stageTaskIds = (stageDetails?.taskIds as string[][]) || []; + const stageWidth = (parentStage.style?.width as number) || 304; + + // Recalculate positions for all tasks in the stage + const positions = calculateTaskPositions(stageTaskIds, stageWidth, filteredNodes); + + return filteredNodes.map((n) => { + if (n.type === 'task' && n.parentId === draggedNode.parentId) { + const pos = positions.get(n.id); + if (pos) { + return { + ...n, + position: { x: pos.x, y: pos.y }, + }; + } + } + return n; + }); + }); + + // Reset drag state + setDragState({ + isDragging: false, + taskId: null, + sourceStageId: null, + targetStageId: null, + isCopyMode: false, + dropPosition: null, + }); + }, + [handleKeyDown, handleKeyUp, onTaskCopy, onTaskMove, onTaskReorder, setNodes] + ); + + return { + dragState, + handlers: { + onNodeDragStart, + onNodeDrag, + onNodeDragStop, + }, + }; +} diff --git a/packages/apollo-react/src/canvas/hooks/useTaskCopyPaste.ts b/packages/apollo-react/src/canvas/hooks/useTaskCopyPaste.ts new file mode 100644 index 000000000..5ec453aa8 --- /dev/null +++ b/packages/apollo-react/src/canvas/hooks/useTaskCopyPaste.ts @@ -0,0 +1,249 @@ +/** + * useTaskCopyPaste - Hook for task copy/paste functionality + * + * Provides clipboard-based copy and paste operations for tasks. + * Uses a custom clipboard state (not system clipboard) to track copied tasks. + * + * Key features: + * - Ctrl/Cmd+C to copy selected task + * - Ctrl/Cmd+V to paste task into target stage + * - Maintains task data for paste operations + * - Generates new IDs for pasted tasks + */ + +import { useReactFlow } from '@xyflow/react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { TaskNodeData } from '../components/TaskNode/TaskNode.types'; + +/** + * Data stored in clipboard for a copied task + */ +export interface ClipboardTask { + /** Original task ID */ + originalId: string; + /** Source stage ID */ + sourceStageId: string; + /** Task data (without position-specific info) */ + data: Omit; +} + +/** + * Parameters for paste operation + */ +export interface TaskPasteParams { + /** New task ID */ + newTaskId: string; + /** Original task data */ + originalData: Omit; + /** Source stage ID */ + sourceStageId: string; + /** Target stage ID */ + targetStageId: string; + /** Position in target stage (defaults to end) */ + position: { + groupIndex: number; + taskIndex: number; + }; +} + +/** + * Callbacks for copy/paste operations + */ +export interface TaskCopyPasteCallbacks { + /** Called when a task is pasted */ + onTaskPaste?: (params: TaskPasteParams) => void; + /** Called when a task is copied to clipboard */ + onTaskCopy?: (task: ClipboardTask) => void; +} + +/** + * Options for the useTaskCopyPaste hook + */ +export interface UseTaskCopyPasteOptions { + /** Currently selected task ID */ + selectedTaskId?: string | null; + /** Currently focused/target stage ID for paste */ + targetStageId?: string | null; + /** Task IDs in target stage (for calculating paste position) */ + targetTaskIds?: string[][]; + /** Whether copy/paste is enabled */ + enabled?: boolean; +} + +/** + * Hook for task copy/paste functionality + */ +export function useTaskCopyPaste( + callbacks: TaskCopyPasteCallbacks = {}, + options: UseTaskCopyPasteOptions = {} +) { + const { getNode } = useReactFlow(); + const { onTaskPaste, onTaskCopy } = callbacks; + const { + selectedTaskId = null, + targetStageId = null, + targetTaskIds = [], + enabled = true, + } = options; + + // Clipboard state + const [clipboard, setClipboard] = useState(null); + + // Refs for callback dependencies + const selectedTaskIdRef = useRef(selectedTaskId); + const targetStageIdRef = useRef(targetStageId); + const targetTaskIdsRef = useRef(targetTaskIds); + + // Update refs when props change + useEffect(() => { + selectedTaskIdRef.current = selectedTaskId; + targetStageIdRef.current = targetStageId; + targetTaskIdsRef.current = targetTaskIds; + }, [selectedTaskId, targetStageId, targetTaskIds]); + + /** + * Copy the currently selected task to clipboard + */ + const copyTask = useCallback(() => { + const taskId = selectedTaskIdRef.current; + if (!taskId) return false; + + const taskNode = getNode(taskId); + if (!taskNode || taskNode.type !== 'task') return false; + + const taskData = taskNode.data as TaskNodeData; + const sourceStageId = taskNode.parentId; + if (!sourceStageId) return false; + + const clipboardTask: ClipboardTask = { + originalId: taskId, + sourceStageId, + data: { + taskType: taskData.taskType, + label: taskData.label, + icon: taskData.icon, + iconElement: taskData.iconElement, + execution: taskData.execution, + }, + }; + + setClipboard(clipboardTask); + onTaskCopy?.(clipboardTask); + return true; + }, [getNode, onTaskCopy]); + + /** + * Paste the clipboard task into the target stage + */ + const pasteTask = useCallback(() => { + if (!clipboard) return false; + + const targetId = targetStageIdRef.current; + if (!targetId) return false; + + // Verify target is a valid stage + const targetNode = getNode(targetId); + if (!targetNode) return false; + + const nodeType = targetNode.type; + const nodeData = targetNode.data as Record; + const dataNodeType = nodeData?.nodeType as string | undefined; + + // Check if it's a stage node + const isStage = + nodeType === 'stage' || + nodeType === 'stageV2' || + (typeof dataNodeType === 'string' && dataNodeType.includes('Stage')); + + if (!isStage) return false; + + // Generate new task ID + const newTaskId = `task-${crypto.randomUUID()}`; + + // Calculate paste position (at the end of target stage) + const currentTaskIds = targetTaskIdsRef.current; + const position = { + groupIndex: currentTaskIds.length, + taskIndex: 0, + }; + + // Call paste callback + onTaskPaste?.({ + newTaskId, + originalData: clipboard.data, + sourceStageId: clipboard.sourceStageId, + targetStageId: targetId, + position, + }); + + return true; + }, [clipboard, getNode, onTaskPaste]); + + /** + * Check if clipboard has content + */ + const hasClipboardContent = clipboard !== null; + + /** + * Clear the clipboard + */ + const clearClipboard = useCallback(() => { + setClipboard(null); + }, []); + + /** + * Keyboard event handler + */ + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (!enabled) return; + + const isModifierPressed = event.ctrlKey || event.metaKey; + if (!isModifierPressed) return; + + // Ignore if typing in an input + const target = event.target as HTMLElement; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { + return; + } + + switch (event.key.toLowerCase()) { + case 'c': + if (copyTask()) { + event.preventDefault(); + } + break; + case 'v': + if (pasteTask()) { + event.preventDefault(); + } + break; + } + }, + [enabled, copyTask, pasteTask] + ); + + // Set up keyboard listeners + useEffect(() => { + if (!enabled) return; + + // Use capture phase so the event fires before React Flow can stop propagation + window.addEventListener('keydown', handleKeyDown, { capture: true }); + return () => { + window.removeEventListener('keydown', handleKeyDown, { capture: true }); + }; + }, [enabled, handleKeyDown]); + + return { + /** Current clipboard content */ + clipboard, + /** Whether clipboard has content */ + hasClipboardContent, + /** Copy the selected task to clipboard */ + copyTask, + /** Paste clipboard content to target stage */ + pasteTask, + /** Clear the clipboard */ + clearClipboard, + }; +} diff --git a/packages/apollo-react/src/canvas/schema/index.ts b/packages/apollo-react/src/canvas/schema/index.ts index 6ef2989f1..7697f9f44 100644 --- a/packages/apollo-react/src/canvas/schema/index.ts +++ b/packages/apollo-react/src/canvas/schema/index.ts @@ -1,3 +1,4 @@ export * from './node-definition'; export * from './node-instance'; +export * from './task-definition'; export * from './toolbar'; diff --git a/packages/apollo-react/src/canvas/schema/task-definition/index.ts b/packages/apollo-react/src/canvas/schema/task-definition/index.ts new file mode 100644 index 000000000..99f6a2e7f --- /dev/null +++ b/packages/apollo-react/src/canvas/schema/task-definition/index.ts @@ -0,0 +1 @@ +export * from './task-manifest'; diff --git a/packages/apollo-react/src/canvas/schema/task-definition/task-manifest.ts b/packages/apollo-react/src/canvas/schema/task-definition/task-manifest.ts new file mode 100644 index 000000000..bff04fa55 --- /dev/null +++ b/packages/apollo-react/src/canvas/schema/task-definition/task-manifest.ts @@ -0,0 +1,117 @@ +/** + * Task Manifest Schemas + * + * Zod schemas for task type definitions within stage nodes. + * Tasks are items that can be added to stages and represent + * individual work items or automation steps. + */ + +import { z } from 'zod'; + +/** + * Existing task types used with "uipath.case-management." prefix: + * - process + * - agent + * - external-agent + * - rpa + * - action + * - api-workflow + * - wait-for-timer + * - wait-for-connector + * - run-human-action + * - execute-connector-activity + */ + +/** + * Display configuration for a task + */ +export const taskDisplayManifestSchema = z.object({ + /** Human-readable display name */ + label: z.string().min(1), + + /** Description of what the task does */ + description: z.string().optional(), + + /** Icon identifier (e.g., "human-action", "robot") */ + icon: z.string().min(1), +}); + +/** + * Complete task manifest for registration + */ +export const taskManifestSchema = z.object({ + // Core identification + /** Unique task type identifier (e.g., "uipath.case-management.run-human-action") */ + taskType: z.string().min(1), + + /** Version of the task definition */ + version: z.string().min(1), + + // Categorization + /** Category ID this task belongs to (for toolbox grouping) */ + category: z.string().optional(), + + /** Tags for search and filtering */ + tags: z.array(z.string()), + + /** Sort order within category */ + sortOrder: z.number().int().nonnegative(), + + // Visual configuration + /** Display configuration including label, icon, description */ + display: taskDisplayManifestSchema, + + // Stage constraints + /** + * Stage node types where this task can be added. + * If omitted or empty, task can be added to any stage. + * Example: ["case-management:Stage"] - only stages, not triggers + */ + allowedStageTypes: z.array(z.string()).optional(), + + // Default values + /** Default property values when creating a new task */ + defaultProperties: z.record(z.string(), z.unknown()).optional(), + + // Optional metadata + /** Whether the task type is deprecated */ + deprecated: z.boolean().optional(), +}); + +/** + * Collection of task manifests (for bulk registration) + */ +export const taskManifestCollectionSchema = z.array(taskManifestSchema); + +// Export inferred types +export type TaskDisplayManifest = z.infer; +export type TaskManifest = z.infer; +export type TaskManifestCollection = z.infer; + +/** + * Validate a task manifest + */ +export function validateTaskManifest(manifest: unknown): TaskManifest { + return taskManifestSchema.parse(manifest); +} + +/** + * Validate a collection of task manifests + */ +export function validateTaskManifestCollection(manifests: unknown): TaskManifestCollection { + return taskManifestCollectionSchema.parse(manifests); +} + +/** + * Check if a task type is allowed in a specific stage type + */ +export function isTaskAllowedInStage( + manifest: TaskManifest, + stageNodeType: string +): boolean { + // If no restrictions, task is allowed everywhere + if (!manifest.allowedStageTypes || manifest.allowedStageTypes.length === 0) { + return true; + } + return manifest.allowedStageTypes.includes(stageNodeType); +}