diff --git a/packages/apollo-react/src/canvas/components/StageNode/AdhocTask.test.tsx b/packages/apollo-react/src/canvas/components/StageNode/AdhocTask.test.tsx new file mode 100644 index 000000000..25fdbd868 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/StageNode/AdhocTask.test.tsx @@ -0,0 +1,307 @@ +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 { AdhocTaskItem } from './AdhocTask'; +import type { StageTaskItem } from './StageNode.types'; + +const createTask = (id: string, label?: string): StageTaskItem => ({ + id, + label: label ?? `Task ${id}`, + isAdhoc: true, +}); + +const createMenuItems = (onRemoveClick: () => void): NodeMenuItem[] => [ + { + id: 'replace-task', + label: 'Replace task', + onClick: vi.fn(), + }, + { + type: 'divider' as const, + }, + { + id: 'remove-task', + label: 'Delete task', + onClick: onRemoveClick, + }, +]; + +describe('AdhocTaskItem', () => { + const defaultProps = { + task: createTask('adhoc-1', 'Adhoc Task'), + taskExecution: undefined, + isSelected: false, + contextMenuItems: [] as NodeMenuItem[], + onTaskClick: vi.fn(), + }; + + describe('Rendering', () => { + it('renders task with correct testid', () => { + render(); + + expect(screen.getByTestId('stage-task-adhoc-1')).toBeInTheDocument(); + }); + + it('renders task label', () => { + render(); + + expect(screen.getByText('Adhoc Task')).toBeInTheDocument(); + }); + + it('renders with selected state', () => { + render(); + + expect(screen.getByTestId('stage-task-adhoc-1')).toBeInTheDocument(); + }); + }); + + describe('Task Click Behavior', () => { + it('calls onTaskClick when task is clicked', async () => { + const user = userEvent.setup(); + const onTaskClick = vi.fn(); + + render(); + + const task = screen.getByTestId('stage-task-adhoc-1'); + await user.click(task); + + expect(onTaskClick).toHaveBeenCalledTimes(1); + expect(onTaskClick).toHaveBeenCalledWith(expect.any(Object), 'adhoc-1'); + }); + + it('prevents task click when menu is open', async () => { + const user = userEvent.setup(); + const onTaskClick = vi.fn(); + const onRemove = vi.fn(); + const menuItems = createMenuItems(onRemove); + + render( + + ); + + // Open menu + const menuButton = screen.getByTestId('stage-task-menu-adhoc-1'); + await user.click(menuButton); + + await waitFor(() => { + expect(screen.getByText('Replace task')).toBeInTheDocument(); + }); + + // Try to click the task while menu is open + const task = screen.getByTestId('stage-task-adhoc-1'); + await user.click(task); + + expect(onTaskClick).not.toHaveBeenCalled(); + }); + + it('allows task click after menu is closed', async () => { + const user = userEvent.setup(); + const onTaskClick = vi.fn(); + const onRemove = vi.fn(); + const menuItems = createMenuItems(onRemove); + + render( + + ); + + // Open menu + const menuButton = screen.getByTestId('stage-task-menu-adhoc-1'); + await user.click(menuButton); + + await waitFor(() => { + expect(screen.getByText('Replace task')).toBeInTheDocument(); + }); + + // Click a menu item to close it + const replaceItem = screen.getByText('Replace task'); + await user.click(replaceItem); + + await waitFor(() => { + expect(screen.queryByText('Replace task')).not.toBeInTheDocument(); + }); + + // Now task click should work + const task = screen.getByTestId('stage-task-adhoc-1'); + await user.click(task); + + expect(onTaskClick).toHaveBeenCalledWith(expect.any(Object), 'adhoc-1'); + }); + }); + + describe('Play Button', () => { + it('does not render play button when onTaskPlay is not provided', () => { + render(); + + expect(screen.queryByTestId('stage-task-play-adhoc-1')).not.toBeInTheDocument(); + }); + + it('renders play button when onTaskPlay is provided', () => { + const onTaskPlay = vi.fn().mockResolvedValue(undefined); + + render(); + + expect(screen.getByTestId('stage-task-play-adhoc-1')).toBeInTheDocument(); + }); + + it('calls onTaskPlay when play button is clicked', async () => { + const user = userEvent.setup(); + const onTaskPlay = vi.fn().mockResolvedValue(undefined); + + render(); + + const playButton = screen.getByTestId('stage-task-play-adhoc-1'); + await user.click(playButton); + + expect(onTaskPlay).toHaveBeenCalledWith('adhoc-1'); + }); + + it('does not trigger task click when play button is clicked', async () => { + const user = userEvent.setup(); + const onTaskClick = vi.fn(); + const onTaskPlay = vi.fn().mockResolvedValue(undefined); + + render(); + + const playButton = screen.getByTestId('stage-task-play-adhoc-1'); + await user.click(playButton); + + expect(onTaskClick).not.toHaveBeenCalled(); + }); + + it('shows loading indicator while task play is in progress', async () => { + const user = userEvent.setup(); + let resolvePlay: () => void; + const onTaskPlay = vi.fn( + () => + new Promise((resolve) => { + resolvePlay = resolve; + }) + ); + + render(); + + const playButton = screen.getByTestId('stage-task-play-adhoc-1'); + await user.click(playButton); + + // Should show circular progress while loading + await waitFor(() => { + expect(screen.getByTestId('ap-circular-progress')).toBeInTheDocument(); + }); + + // Resolve the play promise + resolvePlay!(); + + // Loading indicator should disappear + await waitFor(() => { + expect(screen.queryByTestId('ap-circular-progress')).not.toBeInTheDocument(); + }); + }); + + it('recovers from play error and hides loading indicator', async () => { + const user = userEvent.setup(); + const onTaskPlay = vi.fn().mockRejectedValue(new Error('play failed')); + + render(); + + const playButton = screen.getByTestId('stage-task-play-adhoc-1'); + await user.click(playButton); + + // Loading should eventually clear after error + await waitFor(() => { + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + }); + }); + + describe('Context Menu', () => { + it('renders menu button when contextMenuItems are provided', () => { + const onRemove = vi.fn(); + const menuItems = createMenuItems(onRemove); + + render(); + + expect(screen.getByTestId('stage-task-menu-adhoc-1')).toBeInTheDocument(); + }); + + it('does not render menu button when contextMenuItems is empty', () => { + render(); + + expect(screen.queryByTestId('stage-task-menu-adhoc-1')).not.toBeInTheDocument(); + }); + + it('opens menu when button is clicked', async () => { + const user = userEvent.setup(); + const onRemove = vi.fn(); + const menuItems = createMenuItems(onRemove); + + render(); + + const menuButton = screen.getByTestId('stage-task-menu-adhoc-1'); + await user.click(menuButton); + + await waitFor(() => { + expect(screen.getByText('Replace task')).toBeInTheDocument(); + expect(screen.getByText('Delete task')).toBeInTheDocument(); + }); + }); + + it('triggers menu item onClick when clicked', async () => { + const user = userEvent.setup(); + const onRemove = vi.fn(); + const menuItems = createMenuItems(onRemove); + + render(); + + const menuButton = screen.getByTestId('stage-task-menu-adhoc-1'); + await user.click(menuButton); + + await waitFor(() => { + expect(screen.getByText('Delete task')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Delete task')); + + expect(onRemove).toHaveBeenCalledTimes(1); + }); + + it('closes menu after menu item is clicked', async () => { + const user = userEvent.setup(); + const onRemove = vi.fn(); + const menuItems = createMenuItems(onRemove); + + render(); + + const menuButton = screen.getByTestId('stage-task-menu-adhoc-1'); + await user.click(menuButton); + + await waitFor(() => { + expect(screen.getByText('Delete task')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Delete task')); + + await waitFor(() => { + expect(screen.queryByText('Delete task')).not.toBeInTheDocument(); + }); + }); + }); + + describe('onMenuOpen callback', () => { + it('calls onMenuOpen when menu is opened', 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-adhoc-1'); + await user.click(menuButton); + + expect(onMenuOpen).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/StageNode/AdhocTask.tsx b/packages/apollo-react/src/canvas/components/StageNode/AdhocTask.tsx new file mode 100644 index 000000000..3c20d00a3 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/StageNode/AdhocTask.tsx @@ -0,0 +1,131 @@ +import { Spacing } from '@uipath/apollo-core'; +import { ApCircularProgress, ApIconButton, ApTooltip } from '@uipath/apollo-react/material'; +import debounce from 'debounce'; +import { memo, useCallback, useMemo, useRef, useState } from 'react'; +import { PlayIcon } from '../../icons'; +import type { NodeMenuItem } from '../NodeContextMenu'; +import { TaskContent } from './DraggableTask'; +import { StageTask } from './StageNode.styles'; +import type { StageTaskExecution, StageTaskItem } from './StageNode.types'; +import { TaskMenu, type TaskMenuHandle } from './TaskMenu'; + +const AdhocTaskPlayButton = memo( + ({ taskId, onTaskPlay }: { taskId: string; onTaskPlay: (taskId: string) => Promise }) => { + const [playLoading, setPlayLoading] = useState(false); + + const debouncedTaskPlay = useMemo( + () => + debounce( + async (id: string) => { + setPlayLoading(true); + try { + await onTaskPlay(id); + } catch { + // Do nothing + } finally { + setPlayLoading(false); + } + }, + 500, + { immediate: true } + ), + [onTaskPlay] + ); + + const handlePlayClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + debouncedTaskPlay(taskId); + }, + [debouncedTaskPlay, taskId] + ); + + return ( + + e.stopPropagation()} + onKeyDown={(e: React.KeyboardEvent) => e.stopPropagation()} + className="task-menu-icon-button" + sx={{ + color: 'var(--uix-canvas-primary) !important', + minWidth: 'unset !important', + width: `${Spacing.SpacingL} !important`, + height: `${Spacing.SpacingL} !important`, + padding: '0 !important', + }} + > + {playLoading ? : } + + + ); + } +); + +interface AdhocTaskItemProps { + task: StageTaskItem; + taskExecution?: StageTaskExecution; + isSelected: boolean; + contextMenuItems: NodeMenuItem[]; + onTaskClick: (e: React.MouseEvent, taskId: string) => void; + onTaskPlay?: (taskId: string) => Promise; + onMenuOpen?: () => void; +} + +const AdhocTaskItemComponent = ({ + task, + taskExecution, + isSelected, + contextMenuItems, + onTaskClick, + onTaskPlay, + onMenuOpen, +}: AdhocTaskItemProps) => { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const taskRef = useRef(null); + const menuRef = useRef(null); + + const handleClick = useCallback( + (e: React.MouseEvent) => { + 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); + }, []); + + return ( + 0 && { onContextMenu: handleContextMenu })} + > + + {onTaskPlay && } + {contextMenuItems.length > 0 && ( + + )} + + ); +}; + +export const AdhocTaskItem = memo(AdhocTaskItemComponent); 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 37bd92bce..9f205bae3 100644 --- a/packages/apollo-react/src/canvas/components/StageNode/StageNode.stories.tsx +++ b/packages/apollo-react/src/canvas/components/StageNode/StageNode.stories.tsx @@ -1676,7 +1676,7 @@ export const AdhocTasks: Story = { width: 304, data: { stageDetails: { - label: 'Without onTaskPlay', + label: 'Without onTaskPlay and Menu', tasks: [ [ { @@ -1707,6 +1707,14 @@ export const AdhocTasks: Story = { onTaskClick: (taskId: string) => { window.alert(`Task clicked: ${taskId}`); }, + onTaskGroupModification: (type: string, groupIndex: number, taskIndex: number) => { + console.log( + `Task group modification: ${type}, group: ${groupIndex}, task: ${taskIndex}` + ); + }, + onReplaceTaskFromToolbox: (task: unknown, groupIndex: number, taskIndex: number) => { + console.log(`Replace task at group: ${groupIndex}, task: ${taskIndex}`, task); + }, }, }, { 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 d9e90e795..d39bd6754 100644 --- a/packages/apollo-react/src/canvas/components/StageNode/StageNode.styles.ts +++ b/packages/apollo-react/src/canvas/components/StageNode/StageNode.styles.ts @@ -101,7 +101,7 @@ export const StageTitleInput = styled.input<{ border-radius: 2px; width: 100%; min-width: 100px; - padding: ${(props) => (props.isStageTitleEditable ? '0' : `${Padding.PadS} 0px`)}; + padding: ${Padding.PadS} 0px; &:focus { outline: none; diff --git a/packages/apollo-react/src/canvas/components/StageNode/StageNode.tsx b/packages/apollo-react/src/canvas/components/StageNode/StageNode.tsx index b174c1b65..1ae051bed 100644 --- a/packages/apollo-react/src/canvas/components/StageNode/StageNode.tsx +++ b/packages/apollo-react/src/canvas/components/StageNode/StageNode.tsx @@ -20,18 +20,17 @@ import { FontVariantToken, Icon, 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 { - ApCircularProgress, ApIcon, ApIconButton, ApLink, ApTooltip, ApTypography, } from '@uipath/apollo-react/material'; -import debounce from 'debounce'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { EntryConditionIcon, ExitConditionIcon, PlayIcon, ReturnToOriginIcon } from '../../icons'; +import { EntryConditionIcon, ExitConditionIcon, ReturnToOriginIcon } from '../../icons'; import type { HandleGroupManifest } from '../../schema/node-definition'; +import { GroupModificationType } from '../../utils/GroupModificationUtils'; import { useConnectedHandles } from '../BaseCanvas/ConnectedHandlesContext'; import { useButtonHandles } from '../ButtonHandle/useButtonHandles'; import { ExecutionStatusIcon } from '../ExecutionStatusIcon'; @@ -39,6 +38,7 @@ import { FloatingCanvasPanel } from '../FloatingCanvasPanel'; import { NodeContextMenu, type NodeMenuItem } from '../NodeContextMenu'; import { useNodeSelection } from '../NodePropertiesPanel/hooks'; import { type ListItem, Toolbox } from '../Toolbox'; +import { AdhocTaskItem } from './AdhocTask'; import { DraggableTask, TaskContent } from './DraggableTask'; import { INDENTATION_WIDTH, @@ -77,61 +77,6 @@ const CHIP_ICONS: Record = { [StageHeaderChipType.CaseCompletion]: , }; -const AdhocTaskPlayButton = memo( - ({ taskId, onTaskPlay }: { taskId: string; onTaskPlay: (taskId: string) => Promise }) => { - const [playLoading, setPlayLoading] = useState(false); - - const debouncedTaskPlay = useMemo( - () => - debounce( - async (id: string) => { - setPlayLoading(true); - try { - await onTaskPlay(id); - } catch { - // Do nothing - } finally { - setPlayLoading(false); - } - }, - 500, - { immediate: true } - ), - [onTaskPlay] - ); - - const handlePlayClick = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - debouncedTaskPlay(taskId); - }, - [debouncedTaskPlay, taskId] - ); - - return ( - - e.stopPropagation()} - onKeyDown={(e: React.KeyboardEvent) => e.stopPropagation()} - className="task-menu-icon-button" - sx={{ - color: 'var(--uix-canvas-primary) !important', - minWidth: 'unset !important', - width: `${Spacing.SpacingL} !important`, - height: `${Spacing.SpacingL} !important`, - padding: '0 !important', - }} - > - {playLoading ? : } - - - ); - } -); - const StageNodeComponent = (props: StageNodeProps) => { const { dragging, @@ -168,7 +113,15 @@ const StageNodeComponent = (props: StageNodeProps) => { () => allTasks.filter((group) => group.some((t) => !t.isAdhoc)), [allTasks] ); - const adhocTasks = useMemo(() => allTasks.flat().filter((t) => t.isAdhoc), [allTasks]); + const adhocTasks = useMemo( + () => + allTasks.flatMap((group, groupIndex) => + group + .map((task, taskIndex) => ({ task, groupIndex, taskIndex })) + .filter(({ task }) => task.isAdhoc) + ), + [allTasks] + ); const flatTasks = useMemo(() => tasks.flat(), [tasks]); const taskIds = useMemo(() => flatTasks.map((task) => task.id), [flatTasks]); @@ -373,6 +326,38 @@ const StageNodeComponent = (props: StageNodeProps) => { ] ); + const getAdhocContextMenuItems = useCallback( + (groupIndex: number, taskIndex: number, taskId: string): NodeMenuItem[] => { + const items: NodeMenuItem[] = []; + + if (onReplaceTaskFromToolbox) { + items.push( + getMenuItem('replace-task', 'Replace task', () => { + taskStateReference.current = { + isParallel: false, + groupIndex, + taskIndex, + }; + onTaskClick?.(taskId); + setIsReplacingTask(true); + }) + ); + } + + if (onTaskGroupModification) { + if (items.length > 0) items.push(getDivider()); + items.push( + getMenuItem('remove-task', 'Delete task', () => + reGroupTaskFunction(GroupModificationType.REMOVE_TASK, groupIndex, taskIndex) + ) + ); + } + + return items; + }, + [onReplaceTaskFromToolbox, onTaskClick, onTaskGroupModification, reGroupTaskFunction] + ); + const { setSelectedNodeId } = useNodeSelection(); const handleStageClick = useCallback(() => { onStageClick?.(); @@ -583,8 +568,6 @@ const StageNodeComponent = (props: StageNodeProps) => { ); return ( - // biome-ignore lint/a11y/useKeyWithClickEvents: moved over - // biome-ignore lint/a11y/noStaticElementInteractions: moved over
{ - {adhocTasks.map((task) => { + {adhocTasks.map(({ task, groupIndex, taskIndex }) => { const taskExecution = execution?.taskStatus?.[task.id]; + const menuItems = getAdhocContextMenuItems(groupIndex, taskIndex, task.id); return ( - handleTaskClick(e, task.id)} - > - - {onTaskPlay && ( - - )} - + task={task} + taskExecution={taskExecution} + isSelected={selectedTaskId === task.id} + contextMenuItems={menuItems} + onTaskClick={handleTaskClick} + onTaskPlay={onTaskPlay} + {...((onTaskGroupModification || onReplaceTaskFromToolbox) && { + onMenuOpen: () => { + taskStateReference.current = { + isParallel: false, + groupIndex, + taskIndex, + }; + }, + })} + /> ); })} diff --git a/packages/apollo-react/src/canvas/components/StageNode/TaskMenu.tsx b/packages/apollo-react/src/canvas/components/StageNode/TaskMenu.tsx index 0e242852a..d8f1428ac 100644 --- a/packages/apollo-react/src/canvas/components/StageNode/TaskMenu.tsx +++ b/packages/apollo-react/src/canvas/components/StageNode/TaskMenu.tsx @@ -1,4 +1,4 @@ -import { Spacing } from '@uipath/apollo-core'; +import token, { Spacing } from '@uipath/apollo-core'; import { ApIcon, ApIconButton, ApMenu } from '@uipath/apollo-react/material'; import { forwardRef, @@ -11,7 +11,6 @@ import { } 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; diff --git a/packages/apollo-react/src/canvas/components/StageNode/index.ts b/packages/apollo-react/src/canvas/components/StageNode/index.ts index d66def283..33fc4998a 100644 --- a/packages/apollo-react/src/canvas/components/StageNode/index.ts +++ b/packages/apollo-react/src/canvas/components/StageNode/index.ts @@ -1,7 +1,6 @@ export { StageConnectionEdge } from './StageConnectionEdge'; export { StageEdge } from './StageEdge'; export { StageNode } from './StageNode'; -export { StageHeaderChipType } from './StageNode.types'; export type { StageHeaderChip, StageNodeProps, @@ -9,3 +8,4 @@ export type { StageTaskItem, StageTaskStatus, } from './StageNode.types'; +export { StageHeaderChipType } from './StageNode.types';