From 221f372e0a9e08dc8a8d2f5e648111a80e7c7a09 Mon Sep 17 00:00:00 2001 From: Alfredo Mendez Ruiz Date: Wed, 15 Jul 2026 17:13:48 -0700 Subject: [PATCH] feat(apollo-react): add task breakpoints to case-management StageNode [MST-12196] --- .../canvas/components/StageNode/AdhocTask.tsx | 2 + .../StageNode/DraggableTask.test.tsx | 20 ++ .../components/StageNode/DraggableTask.tsx | 2 + .../components/StageNode/EventDrivenTask.tsx | 2 + .../StageNode.breakpoints.stories.tsx | 308 ++++++++++++++++++ .../components/StageNode/StageNode.test.tsx | 55 ++++ .../components/StageNode/StageNode.types.ts | 6 + .../StageNode/StageNode.utils.test.ts | 2 +- .../StageNode/StageNodeAdhocTaskGroups.tsx | 17 +- .../StageNodeEventDrivenTaskGroups.tsx | 17 +- .../StageNodeSequentialTaskGroups.tsx | 19 +- .../StageNode/TaskBreakpointDot.test.tsx | 33 ++ .../StageNode/TaskBreakpointDot.tsx | 33 ++ 13 files changed, 488 insertions(+), 28 deletions(-) create mode 100644 packages/apollo-react/src/canvas/components/StageNode/StageNode.breakpoints.stories.tsx create mode 100644 packages/apollo-react/src/canvas/components/StageNode/TaskBreakpointDot.test.tsx create mode 100644 packages/apollo-react/src/canvas/components/StageNode/TaskBreakpointDot.tsx diff --git a/packages/apollo-react/src/canvas/components/StageNode/AdhocTask.tsx b/packages/apollo-react/src/canvas/components/StageNode/AdhocTask.tsx index 6eb18a584..abe604ef4 100644 --- a/packages/apollo-react/src/canvas/components/StageNode/AdhocTask.tsx +++ b/packages/apollo-react/src/canvas/components/StageNode/AdhocTask.tsx @@ -2,6 +2,7 @@ import { memo, useCallback, useRef } from 'react'; import type { NodeMenuItem } from '../NodeContextMenu'; import { StageTask } from './StageNode.styles'; import type { StageTaskExecution, StageTaskItem } from './StageNode.types'; +import { TaskBreakpointDot } from './TaskBreakpointDot'; import { TaskContent } from './TaskContent'; import { TaskMenu, type TaskMenuHandle } from './TaskMenu'; @@ -50,6 +51,7 @@ const AdhocTaskItemComponent = ({ onClick={handleClick} {...(getContextMenuItems && !isTaskLoading && { onContextMenu: handleContextMenu })} > + {getContextMenuItems && ( { expect(container.querySelector('[data-testid="stage-task-task-1"]')).toBeInTheDocument(); }); }); + + describe('Breakpoint marker', () => { + it('renders the breakpoint dot for a sequential task with a breakpoint set', () => { + render(); + + expect(screen.getByTestId('stage-task-breakpoint-task-1')).toBeInTheDocument(); + }); + + it('does not render the breakpoint dot when no breakpoint is set', () => { + render(); + + expect(screen.queryByTestId('stage-task-breakpoint-task-1')).not.toBeInTheDocument(); + }); + + it('marker is display-only and does not intercept pointer events', () => { + render(); + + expect(screen.getByTestId('stage-task-breakpoint-task-1')).toHaveClass('pointer-events-none'); + }); + }); }); diff --git a/packages/apollo-react/src/canvas/components/StageNode/DraggableTask.tsx b/packages/apollo-react/src/canvas/components/StageNode/DraggableTask.tsx index 59847c589..06e95996e 100644 --- a/packages/apollo-react/src/canvas/components/StageNode/DraggableTask.tsx +++ b/packages/apollo-react/src/canvas/components/StageNode/DraggableTask.tsx @@ -10,6 +10,7 @@ import { StageTaskDragPlaceholderWrapper, StageTaskWrapper, } from './StageNode.styles'; +import { TaskBreakpointDot } from './TaskBreakpointDot'; import { TaskContent } from './TaskContent'; import { TaskMenu, type TaskMenuHandle } from './TaskMenu'; @@ -91,6 +92,7 @@ const DraggableTaskComponent = ({ onClick={handleClick} {...(getContextMenuItems && !isTaskLoading && { onContextMenu: handleContextMenu })} > + {getContextMenuItems && ( diff --git a/packages/apollo-react/src/canvas/components/StageNode/EventDrivenTask.tsx b/packages/apollo-react/src/canvas/components/StageNode/EventDrivenTask.tsx index aee684962..a89ddb9fd 100644 --- a/packages/apollo-react/src/canvas/components/StageNode/EventDrivenTask.tsx +++ b/packages/apollo-react/src/canvas/components/StageNode/EventDrivenTask.tsx @@ -2,6 +2,7 @@ import { memo, useCallback, useRef } from 'react'; import type { NodeMenuItem } from '../NodeContextMenu'; import { StageTask } from './StageNode.styles'; import type { StageTaskExecution, StageTaskItem } from './StageNode.types'; +import { TaskBreakpointDot } from './TaskBreakpointDot'; import { TaskContent } from './TaskContent'; import { TaskMenu, type TaskMenuHandle } from './TaskMenu'; @@ -48,6 +49,7 @@ const EventDrivenTaskItemComponent = ({ onClick={handleClick} {...(getContextMenuItems && !isTaskLoading && { onContextMenu: handleContextMenu })} > + {getContextMenuItems && ( diff --git a/packages/apollo-react/src/canvas/components/StageNode/StageNode.breakpoints.stories.tsx b/packages/apollo-react/src/canvas/components/StageNode/StageNode.breakpoints.stories.tsx new file mode 100644 index 000000000..a7075109c --- /dev/null +++ b/packages/apollo-react/src/canvas/components/StageNode/StageNode.breakpoints.stories.tsx @@ -0,0 +1,308 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { + ConnectionMode, + Panel, + ReactFlowProvider, + useEdgesState, + useNodesState, +} from '@uipath/apollo-react/canvas/xyflow/react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { DefaultCanvasTranslations } from '../../types'; +import { BaseCanvas } from '../BaseCanvas'; +import { CanvasPositionControls } from '../CanvasPositionControls'; +import type { NodeMenuItem } from '../NodeContextMenu'; +import { StageConnectionEdge } from './StageConnectionEdge'; +import { StageEdge } from './StageEdge'; +import { StageNode } from './StageNode'; +import { StageNodeWrapper } from './StageNode.stories.utils'; +import type { + StageNodeProps, + StageTaskExecution, + StageTaskItem, + StageTaskStatus, +} from './StageNode.types'; + +// ============================================================================ +// StageNode: Breakpoints feature +// +// A task carries a breakpoint when its `execution.taskStatus[id].breakpoint` is +// true. The marker is a solid red dot at the top-left corner of the task card +// (debugger-gutter style) and is display-only. Adding and removing breakpoints is +// done through the task's right-click / task menu, whose items the consumer +// (PO.frontend) supplies via `getTaskContextMenuItems`; these stories wire that up +// the same way. +// ============================================================================ + +const ProcessIcon = () => ( + + + + + + +); + +const VerificationIcon = () => ( + + + + +); + +const DocumentIcon = () => ( + + + + + + +); + +const meta: Meta = { + title: 'Components/Nodes/StageNode/Breakpoints', + component: StageNode, + parameters: { + layout: 'fullscreen', + }, +}; + +export default meta; +type Story = StoryObj; + +const BREAKPOINT_TASKS: StageTaskItem[][] = [ + [{ id: 'classify', label: 'Classify Request', icon: }], + [{ id: 'review', label: 'Review and Decide', icon: }], + [{ id: 'notify', label: 'Notify Outcome', icon: }], +]; + +// Reusable interactive playground: manages breakpoint state for an arbitrary task +// list. A set breakpoint shows a solid, display-only marker; adding and removing +// is done through the task's right-click menu (wired via getTaskContextMenuItems), +// exactly as the consumer (PO.frontend) does it. +const BreakpointPlaygroundStory = ({ + stageLabel, + tasks, + initialBreakpoints, + taskStatuses, + allowBreakpoint, + isReadOnly, +}: { + stageLabel: string; + tasks: StageTaskItem[][]; + initialBreakpoints: string[]; + /** Fixed execution statuses per task id (e.g. a paused or completed task). */ + taskStatuses?: Record; + /** Optional predicate gating which tasks can host a breakpoint. */ + allowBreakpoint?: (task: StageTaskItem) => boolean; + /** Render the stage read-only (like Debug view). The breakpoint menu must still work. */ + isReadOnly?: boolean; +}) => { + const nodeTypes = useMemo(() => ({ stage: StageNodeWrapper }), []); + const edgeTypes = useMemo(() => ({ stage: StageEdge }), []); + + const [breakpoints, setBreakpoints] = useState>(() => new Set(initialBreakpoints)); + + const toggleBreakpoint = useCallback((taskId: string) => { + setBreakpoints((prev) => { + const next = new Set(prev); + if (next.has(taskId)) { + next.delete(taskId); + } else { + next.add(taskId); + } + return next; + }); + }, []); + + // Derives the stage node's data from the current breakpoint set plus the fixed + // task statuses. A set breakpoint is a static solid dot; a paused task shows the + // card's amber border and pause icon (from its status), not a changed dot. + const buildData = useCallback(() => { + const taskStatus: Record = {}; + let anyPaused = false; + for (const group of tasks) { + for (const task of group) { + const status = taskStatuses?.[task.id]; + const armed = breakpoints.has(task.id); + if (!status && !armed) { + continue; + } + if (status === 'Paused') { + anyPaused = true; + } + taskStatus[task.id] = { + ...(status + ? { status, ...(status === 'Paused' ? { message: 'Paused on breakpoint' } : {}) } + : {}), + ...(armed ? { breakpoint: true } : {}), + }; + } + } + return { + stageDetails: { label: stageLabel, tasks, isReadOnly }, + execution: { + stageStatus: anyPaused ? { status: 'Paused' as const, label: 'Paused on breakpoint' } : {}, + taskStatus, + }, + // Breakpoints are added/removed through the task's right-click menu (works read-only too). + getTaskContextMenuItems: ({ task }: { task: StageTaskItem }): NodeMenuItem[] => { + // Restricted tasks get no breakpoint action. + if (allowBreakpoint && !allowBreakpoint(task)) { + return []; + } + return [ + { + id: 'toggle-breakpoint', + label: breakpoints.has(task.id) ? 'Remove breakpoint' : 'Add breakpoint', + onClick: () => toggleBreakpoint(task.id), + }, + ]; + }, + }; + }, [tasks, taskStatuses, breakpoints, stageLabel, toggleBreakpoint, allowBreakpoint, isReadOnly]); + + const [nodes, setNodes, onNodesChange] = useNodesState([ + { id: 'debug-stage', type: 'stage', position: { x: 320, y: 96 }, data: buildData() }, + ]); + const [edges, , onEdgesChange] = useEdgesState([]); + + // Write fresh data into React Flow's own node state whenever the breakpoint set + // changes, so the stage re-renders immediately. Overlaying a derived `nodes` + // prop did not reliably re-sync to the store, which left a toggle visually + // stale until the next canvas interaction. + useEffect(() => { + setNodes((prev) => + prev.map((node) => (node.id === 'debug-stage' ? { ...node, data: buildData() } : node)) + ); + }, [buildData, setNodes]); + + return ( +
+ + + + + + + +
+ ); +}; + +// The full breakpoint lifecycle in one stage: +// - "Classify Request": completed with a breakpoint -> solid dot (passed). +// - "Review and Decide": the run is paused here on its breakpoint -> solid dot +// plus the card's amber Paused border and pause icon (the dot itself is static). +// - "Notify Outcome": no breakpoint. +// Right-click any task to add / remove its breakpoint. +export const Interactive: Story = { + name: 'Interactive (right-click to add/remove)', + render: () => ( + + ), +}; + +// Breakpoints apply to every task type, not just sequential ones: sequential, +// ad hoc, and event-driven tasks all render the same corner marker. Right-click +// a task to add / remove a breakpoint. +const TASK_TYPE_TASKS: StageTaskItem[][] = [ + [{ id: 'seq-review', label: 'Review and Decide', icon: }], + [{ id: 'adhoc-manual', label: 'Manual Check', icon: , isAdhoc: true }], + [ + { + id: 'evt-escalation', + label: 'On Escalation', + icon: , + taskGroupType: 'event-driven', + }, + ], +]; + +export const AcrossTaskTypes: Story = { + name: 'Across task types (sequential, ad hoc, event-driven)', + render: () => ( + + ), +}; + +// Only some tasks may host a breakpoint. Here only sequential tasks qualify: the +// right-click menu on an ad hoc or event-driven task offers no breakpoint action. +// This mirrors how a consumer suppresses breakpoints on node types that cannot be +// paused on (the story gates this in its own getTaskContextMenuItems). +export const Restricted: Story = { + name: 'Restricted (menu gated per task)', + render: () => ( + !task.isAdhoc && task.taskGroupType !== 'event-driven'} + /> + ), +}; + +// Read-only stage (as in Debug view): editing is disabled, but the breakpoint +// right-click menu (Add/Remove) must still work. "Review and Decide" is paused, +// shown by the amber card border and pause icon. +export const ReadOnlyDebugView: Story = { + name: 'Read-only / Debug view', + render: () => ( + + ), +}; + +// Breakpoints on tasks inside parallel groups. "Credit Check"/"Fraud Check" and +// "Notify Applicant"/"Write Audit Log" are two parallel groups (rendered with the +// parallel bracket + label); breakpoints sit on tasks within them so you can see +// the marker on parallel tasks (add/remove via right-click). +const BREAKPOINT_PARALLEL_TASKS: StageTaskItem[][] = [ + [{ id: 'intake', label: 'Intake Request', icon: }], + [ + { id: 'credit', label: 'Credit Check', icon: }, + { id: 'fraud', label: 'Fraud Check', icon: }, + ], + [ + { id: 'notify-applicant', label: 'Notify Applicant', icon: }, + { id: 'audit-log', label: 'Write Audit Log', icon: }, + ], + [{ id: 'finalize', label: 'Finalize', icon: }], +]; + +export const ParallelTaskGroups: Story = { + name: 'Parallel task groups', + render: () => ( + + ), +}; diff --git a/packages/apollo-react/src/canvas/components/StageNode/StageNode.test.tsx b/packages/apollo-react/src/canvas/components/StageNode/StageNode.test.tsx index 9acc438f2..353f8ad44 100644 --- a/packages/apollo-react/src/canvas/components/StageNode/StageNode.test.tsx +++ b/packages/apollo-react/src/canvas/components/StageNode/StageNode.test.tsx @@ -1311,3 +1311,58 @@ describe('StageNode - getTaskContextMenuItems', () => { expect(onCustom).toHaveBeenCalledTimes(1); }); }); + +// Sequential tasks render through DraggableTask, which is mocked in this suite +// (see vi.mock('./DraggableTask') above), so the real breakpoint dot cannot be +// asserted here for that path. The sequential path is covered against the real +// component in DraggableTask.test.tsx; the ad hoc and event-driven paths use the +// real task components and are verified below. +describe('StageNode - Breakpoints on adhoc and event-driven tasks', () => { + it('renders a breakpoint marker on an armed adhoc task', () => { + renderStageNode({ + stageDetails: { + label: 'Test Stage', + isReadOnly: true, + tasks: [[{ id: 'adhoc-1', label: 'Adhoc Task', isAdhoc: true }]], + }, + execution: { + stageStatus: {}, + taskStatus: { 'adhoc-1': { breakpoint: true } }, + }, + }); + + expect(screen.getByTestId('stage-task-breakpoint-adhoc-1')).toBeInTheDocument(); + }); + + it('renders a breakpoint marker on an armed event-driven task', () => { + renderStageNode({ + stageDetails: { + label: 'Test Stage', + isReadOnly: true, + tasks: [[{ id: 'evt-1', label: 'Event Task', taskGroupType: 'event-driven' }]], + }, + execution: { + stageStatus: {}, + taskStatus: { 'evt-1': { breakpoint: true } }, + }, + }); + + expect(screen.getByTestId('stage-task-breakpoint-evt-1')).toBeInTheDocument(); + }); + + it('does not render a breakpoint marker on an unarmed adhoc task', () => { + renderStageNode({ + stageDetails: { + label: 'Test Stage', + isReadOnly: true, + tasks: [[{ id: 'adhoc-1', label: 'Adhoc Task', isAdhoc: true }]], + }, + execution: { + stageStatus: {}, + taskStatus: {}, + }, + }); + + expect(screen.queryByTestId('stage-task-breakpoint-adhoc-1')).not.toBeInTheDocument(); + }); +}); 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 0bcd4697d..5bdbf5f34 100644 --- a/packages/apollo-react/src/canvas/components/StageNode/StageNode.types.ts +++ b/packages/apollo-react/src/canvas/components/StageNode/StageNode.types.ts @@ -123,6 +123,12 @@ export interface StageTaskExecution { * pre-localized by the consumer. */ retryCount?: number; + /** + * When `true`, the task shows a breakpoint marker (a red gutter dot) indicating the + * debugger will pause on this task. Breakpoints attach to individual tasks, not to the + * stage container. + */ + breakpoint?: boolean; } export interface StageTaskDragOverlayProps { 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 index 06a9ed719..ebdbd7e00 100644 --- a/packages/apollo-react/src/canvas/components/StageNode/StageNode.utils.test.ts +++ b/packages/apollo-react/src/canvas/components/StageNode/StageNode.utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import type { NodeMenuAction, NodeMenuItem } from '../NodeContextMenu'; +import type { NodeMenuItem } from '../NodeContextMenu'; import { INDENTATION_WIDTH } from './StageNode.styles'; import type { StageTaskItem } from './StageNode.types'; import { diff --git a/packages/apollo-react/src/canvas/components/StageNode/StageNodeAdhocTaskGroups.tsx b/packages/apollo-react/src/canvas/components/StageNode/StageNodeAdhocTaskGroups.tsx index 73025757a..72cce62ba 100644 --- a/packages/apollo-react/src/canvas/components/StageNode/StageNodeAdhocTaskGroups.tsx +++ b/packages/apollo-react/src/canvas/components/StageNode/StageNodeAdhocTaskGroups.tsx @@ -78,15 +78,14 @@ const StageNodeAdhocTaskGroupsInner = ({ {adhocTasks.map(({ task }) => { const taskExecution = execution?.taskStatus?.[task.id]; - const customItems = - !isReadOnly && !hasBuiltInTaskActions - ? (getTaskContextMenuItems?.({ - task, - taskGroupType: 'adhoc', - isParallel: false, - }) ?? []) - : []; - const hasMenu = !isReadOnly && (hasBuiltInTaskActions || customItems.length > 0); + // Consumer items (e.g. breakpoints) are allowed even in read-only/Debug view; + // only the built-in edit actions are gated on !isReadOnly. When built-in actions + // already guarantee a menu we skip the eager consumer call; otherwise we ask the + // consumer whether it contributes any items. + const hasMenu = + (!isReadOnly && hasBuiltInTaskActions) || + (getTaskContextMenuItems?.({ task, taskGroupType: 'adhoc', isParallel: false }) + ?.length ?? 0) > 0; return ( {eventDrivenTasks.map(({ task }) => { const taskExecution = execution?.taskStatus?.[task.id]; - const customItems = - !isReadOnly && !hasBuiltInTaskActions - ? (getTaskContextMenuItems?.({ - task, - taskGroupType: 'event-driven', - isParallel: false, - }) ?? []) - : []; - const hasMenu = !isReadOnly && (hasBuiltInTaskActions || customItems.length > 0); + // Consumer items (e.g. breakpoints) are allowed even in read-only/Debug view; + // only the built-in edit actions are gated on !isReadOnly. When built-in actions + // already guarantee a menu we skip the eager consumer call; otherwise we ask the + // consumer whether it contributes any items. + const hasMenu = + (!isReadOnly && hasBuiltInTaskActions) || + (getTaskContextMenuItems?.({ task, taskGroupType: 'event-driven', isParallel: false }) + ?.length ?? 0) > 0; return ( { const taskExecution = execution?.taskStatus?.[task.id]; - const customItems = - !isReadOnly && !hasBuiltInTaskActions - ? (getTaskContextMenuItems?.({ - task, - taskGroupType: 'sequential', - isParallel, - }) ?? []) - : []; + // Consumer items (e.g. breakpoints) are allowed even in read-only/Debug + // view; only the built-in edit actions are gated on !isReadOnly. When + // built-in actions already guarantee a menu we skip the eager consumer + // call; otherwise we ask the consumer whether it contributes any items. const hasMenu = - !isReadOnly && (hasBuiltInTaskActions || customItems.length > 0); + (!isReadOnly && hasBuiltInTaskActions) || + (getTaskContextMenuItems?.({ + task, + taskGroupType: 'sequential', + isParallel, + })?.length ?? 0) > 0; return ( { + it('renders nothing when no breakpoint is set', () => { + render(); + + expect(screen.queryByTestId('stage-task-breakpoint-t1')).not.toBeInTheDocument(); + }); + + it('renders a solid marker when a breakpoint is set', () => { + render(); + + expect(screen.getByTestId('stage-task-breakpoint-t1')).toBeInTheDocument(); + }); + + it('is display-only and never intercepts pointer events', () => { + // Breakpoints are managed via the task menu / hover toolbar; the marker itself + // must let clicks/drags on the card corner pass through to the task. + render(); + + expect(screen.getByTestId('stage-task-breakpoint-t1')).toHaveClass('pointer-events-none'); + }); + + it('is static: the marker never animates (matches Flow, which does not pulse)', () => { + const { container } = render(); + + expect(container.querySelector('[class*="animate-"]')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/StageNode/TaskBreakpointDot.tsx b/packages/apollo-react/src/canvas/components/StageNode/TaskBreakpointDot.tsx new file mode 100644 index 000000000..eccef612b --- /dev/null +++ b/packages/apollo-react/src/canvas/components/StageNode/TaskBreakpointDot.tsx @@ -0,0 +1,33 @@ +import { memo } from 'react'; + +export interface TaskBreakpointDotProps { + /** Whether a breakpoint is set (placed) on this task. */ + active: boolean; + /** Task id, used for a stable test id. */ + taskId: string; +} + +/** + * Breakpoint marker for a stage task, at the top-left corner of the task card. + * + * Mirrors the Flow/BPMN canvas breakpoint (`DebugBreakpoint`): a 14px solid circle + * filled with the canvas error-icon color, no border, shadow, or animation. + * + * Display only: it renders nothing until a breakpoint is set and never intercepts + * pointer events. Adding and removing breakpoints is done through the task's + * right-click menu (items supplied by the consumer via `getTaskContextMenuItems`). + */ +function TaskBreakpointDotInner({ active, taskId }: TaskBreakpointDotProps) { + if (!active) { + return null; + } + + return ( + + ); +} + +export const TaskBreakpointDot = memo(TaskBreakpointDotInner);