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 2e5d38cc2..b781708b1 100644 --- a/packages/apollo-react/src/canvas/components/StageNode/StageNode.stories.tsx +++ b/packages/apollo-react/src/canvas/components/StageNode/StageNode.stories.tsx @@ -460,8 +460,6 @@ export const ExecutionStatus: Story = { label: 'Liability Check', duration: '1h 30m', retryDuration: '25m', - badge: 'Reworked', - badgeStatus: 'error', retryCount: 2, }, '2': { @@ -469,7 +467,6 @@ export const ExecutionStatus: Story = { label: 'Credit Review', duration: '1h 30m', retryDuration: '32m', - badge: 'Reworked', retryCount: 1, }, '3': { status: 'Completed', label: 'Address Verification', duration: '30m' }, @@ -478,7 +475,6 @@ export const ExecutionStatus: Story = { label: 'Property Verification', duration: '1h 30m', retryDuration: '1h 5m', - badge: 'Reworked', retryCount: 3, }, '5': { status: 'Completed', label: 'Processing Review', duration: '1h 15m' }, @@ -791,16 +787,12 @@ export const InteractiveTaskManagement: Story = { '1': { status: 'Completed', duration: '30m', - badge: 'Completed', - badgeStatus: 'info', }, '2': { status: 'InProgress', duration: '1h 15m', durationTooltip: '45m remaining', retryDuration: '15m', - badge: 'Retry', - badgeStatus: 'warning', retryCount: 2, }, '3': { status: 'Completed', duration: '45m' }, @@ -808,8 +800,6 @@ export const InteractiveTaskManagement: Story = { status: 'Failed', duration: '20m', retryDuration: '10m', - badge: 'Error', - badgeStatus: 'error', retryCount: 1, }, '5': { status: 'NotExecuted' }, @@ -2169,8 +2159,6 @@ export const AdhocTasks: Story = { label: 'Verify Address', duration: '1h 20m', retryDuration: '35m', - badge: 'Reworked', - badgeStatus: 'warning', retryCount: 2, }, '2': { @@ -2178,8 +2166,6 @@ export const AdhocTasks: Story = { label: 'Verify Identity', duration: '45m', message: 'Identity verification failed - document expired', - badge: 'Action needed', - badgeStatus: 'error', }, '3': { status: 'InProgress', @@ -2190,8 +2176,6 @@ export const AdhocTasks: Story = { label: 'Review Docs', duration: '30m', retryDuration: '10m', - badge: 'Reworked', - badgeStatus: 'info', retryCount: 1, }, '5': { @@ -2450,8 +2434,6 @@ export const TasksBySection: Story = { label: 'Verify Address', duration: '1h 20m', retryDuration: '35m', - badge: 'Reworked', - badgeStatus: 'warning', retryCount: 2, }, '2': { @@ -2459,8 +2441,6 @@ export const TasksBySection: Story = { label: 'Verify Identity', duration: '45m', message: 'Identity verification failed - document expired', - badge: 'Action needed', - badgeStatus: 'error', }, '3': { status: 'InProgress', @@ -2471,8 +2451,6 @@ export const TasksBySection: Story = { label: 'Review Docs', duration: '30m', retryDuration: '10m', - badge: 'Reworked', - badgeStatus: 'info', retryCount: 1, }, '5': { 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 5bdbf5f34..ce1ebbf0b 100644 --- a/packages/apollo-react/src/canvas/components/StageNode/StageNode.types.ts +++ b/packages/apollo-react/src/canvas/components/StageNode/StageNode.types.ts @@ -113,14 +113,24 @@ export interface StageTaskExecution { duration?: string; /** Tooltip text shown on hover over the duration text (e.g. a wait-for-timer countdown). */ durationTooltip?: string; + /** + * Total time spent re-working/re-running this task (e.g. `"25m"`). When set, a "rework" icon-chip + * (↺) is shown; the value is surfaced as `Reworked (+{retryDuration})` in the chip's tooltip. + */ retryDuration?: string; + /** + * @deprecated No longer rendered. Execution now shows dedicated "runs" and "rework" icon-chips + * derived from `retryCount` and `retryDuration`; consumer-supplied badge text is ignored. + */ badge?: string; + /** + * @deprecated No longer rendered. See `badge`. + */ badgeStatus?: 'warning' | 'info' | 'error'; /** - * Total run count to display in the badge. Requires `badge` to be set. When `> 1`, Apollo - * overrides the `badge` text with a localized "Ran N times" (or "Running again" when `status` is - * `'InProgress'`). When `1` or absent, the `badge` string is rendered as-is and must be - * pre-localized by the consumer. + * Number of re-runs (retries) of this task — i.e. total runs minus one. When `> 0`, a "runs" + * icon-chip (↱) is shown displaying the total run count (`retryCount + 1`), with a localized + * "Ran N times" tooltip (or "Running again" when `status` is `'InProgress'`). */ retryCount?: number; /** diff --git a/packages/apollo-react/src/canvas/components/StageNode/TaskContent.test.tsx b/packages/apollo-react/src/canvas/components/StageNode/TaskContent.test.tsx index 6175d0d21..3e321bd69 100644 --- a/packages/apollo-react/src/canvas/components/StageNode/TaskContent.test.tsx +++ b/packages/apollo-react/src/canvas/components/StageNode/TaskContent.test.tsx @@ -93,74 +93,132 @@ describe('TaskContent - execution status tooltip', () => { }); }); -describe('TaskContent - badge', () => { +describe('TaskContent - runs chip', () => { beforeEach(() => { vi.clearAllMocks(); }); - it('renders "Running again" when retryCount > 1 and status is InProgress', () => { + it('shows the total-run count (retryCount + 1) with a "Ran N times" tooltip when not InProgress', () => { renderTaskContent({ - taskExecution: { status: 'InProgress', duration: '1m', retryCount: 2, badge: 'Running' }, + taskExecution: { status: 'Completed', duration: '1m', retryCount: 2 }, }); - expect(screen.getByText('Running again')).toBeInTheDocument(); + // retryCount = 2 re-runs => 3 total runs. + const chip = screen.getByText('3'); + expect(chip.closest('[data-testid="canvas-tooltip"]')).toHaveAttribute( + 'data-tooltip-content', + 'Ran 3 times' + ); }); - it('renders "Ran N times" when retryCount > 1 and status is not InProgress', () => { + it('shows a "Running again" tooltip while the task is InProgress', () => { renderTaskContent({ - taskExecution: { status: 'Completed', duration: '1m', retryCount: 2, badge: 'Ran' }, + taskExecution: { status: 'InProgress', duration: '1m', retryCount: 2 }, }); - expect(screen.getByText('Ran 2 times')).toBeInTheDocument(); + const chip = screen.getByText('3'); + expect(chip.closest('[data-testid="canvas-tooltip"]')).toHaveAttribute( + 'data-tooltip-content', + 'Running again' + ); }); - it('interpolates the count into the plural form', () => { + it('interpolates the total-run count into the plural tooltip form', () => { renderTaskContent({ - taskExecution: { status: 'Failed', duration: '1m', retryCount: 3, badge: 'Ran' }, + taskExecution: { status: 'Failed', duration: '1m', retryCount: 3 }, }); - expect(screen.getByText('Ran 3 times')).toBeInTheDocument(); + const chip = screen.getByText('4'); + expect(chip.closest('[data-testid="canvas-tooltip"]')).toHaveAttribute( + 'data-tooltip-content', + 'Ran 4 times' + ); + }); + + it('shows the runs chip for a single re-run (retryCount 1 => 2 runs)', () => { + renderTaskContent({ + taskExecution: { status: 'Completed', duration: '1m', retryCount: 1 }, + }); + const chip = screen.getByText('2'); + expect(chip.closest('[data-testid="canvas-tooltip"]')).toHaveAttribute( + 'data-tooltip-content', + 'Ran 2 times' + ); + }); + + it('does not show the runs chip when there were no re-runs', () => { + renderTaskContent({ taskExecution: { status: 'Completed', duration: '1m' } }); + expect(screen.queryByText('1')).not.toBeInTheDocument(); }); - it('overrides the consumer-supplied badge string when retryCount > 1', () => { + it('suppresses a retry-driven consumer badge string in favour of the runs chip', () => { renderTaskContent({ + taskExecution: { status: 'Completed', duration: '1m', retryCount: 2, badge: 'Ran 3 times' }, + }); + expect(screen.getByText('3')).toBeInTheDocument(); + expect(screen.queryByText('Ran 3 times', { selector: 'div' })).not.toBeInTheDocument(); + }); +}); + +describe('TaskContent - rework chip', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('shows the rework chip with the added-time tooltip when a rework duration is present', () => { + const { container } = renderTaskContent({ taskExecution: { status: 'Completed', - duration: '1m', + duration: '1h 30m', retryCount: 2, - badge: 'Reworked', + retryDuration: '25m', }, }); - expect(screen.getByText('Ran 2 times')).toBeInTheDocument(); - expect(screen.queryByText('Reworked')).not.toBeInTheDocument(); + const tooltip = container.querySelector('[data-tooltip-content="Reworked (+25m)"]'); + expect(tooltip).not.toBeNull(); }); - it('renders the consumer-supplied badge string as-is when retryCount is 1', () => { - renderTaskContent({ - taskExecution: { status: 'Completed', duration: '1m', retryCount: 1, badge: 'Ran' }, + it('does not show the rework chip when there is no rework duration', () => { + const { container } = renderTaskContent({ + taskExecution: { status: 'Completed', duration: '1m', retryCount: 2 }, }); - expect(screen.getByText('Ran')).toBeInTheDocument(); + expect(container.querySelector('[data-tooltip-content^="Reworked"]')).toBeNull(); }); +}); - it('renders the consumer-supplied badge string as-is when retryCount is 1 and status is InProgress', () => { - renderTaskContent({ - taskExecution: { status: 'InProgress', duration: '1m', retryCount: 1, badge: 'Running' }, - }); - expect(screen.getByText('Running')).toBeInTheDocument(); +describe('TaskContent - badge suppression', () => { + beforeEach(() => { + vi.clearAllMocks(); }); - it('renders the consumer-supplied badge string when retryCount is absent', () => { + it('does not render a consumer-supplied badge string (only runs/rework chips are shown)', () => { renderTaskContent({ taskExecution: { status: 'Completed', duration: '1m', badge: 'Action needed' }, }); - expect(screen.getByText('Action needed')).toBeInTheDocument(); + expect(screen.queryByText('Action needed')).not.toBeInTheDocument(); + }); + + it('renders no chip when there are no runs and no rework', () => { + renderTaskContent({ taskExecution: { status: 'Completed', duration: '1m' } }); + expect(screen.queryByText(/Ran|Running|Reworked|Action/)).not.toBeInTheDocument(); }); +}); - it('renders no badge when no badge string is supplied (even if retryCount > 1)', () => { - renderTaskContent({ taskExecution: { status: 'Completed', duration: '1m', retryCount: 2 } }); - expect(screen.queryByText(/Ran|Running/)).not.toBeInTheDocument(); +describe('TaskContent - required marker', () => { + beforeEach(() => { + vi.clearAllMocks(); }); - it('renders no badge when neither retryCount nor a badge string is supplied', () => { - renderTaskContent({ taskExecution: { status: 'Completed', duration: '1m' } }); - expect(screen.queryByText(/Ran|Running|Reworked/)).not.toBeInTheDocument(); + it('shows the "*" marker for a required non-adhoc task', () => { + renderTaskContent({ task: { isRequired: true } }); + expect(screen.getByText('*')).toBeInTheDocument(); + }); + + it('hides the "*" marker for a required adhoc task (isAdhoc)', () => { + renderTaskContent({ task: { isRequired: true, isAdhoc: true } }); + expect(screen.queryByText('*')).not.toBeInTheDocument(); + }); + + it('hides the "*" marker for a required adhoc task (taskGroupType)', () => { + renderTaskContent({ task: { isRequired: true, taskGroupType: 'adhoc' } }); + expect(screen.queryByText('*')).not.toBeInTheDocument(); }); }); @@ -208,14 +266,14 @@ describe('TaskContent - entry-condition icon', () => { expect(icon).toHaveAttribute('height', '20'); }); - it('renders a 16px entry-condition diamond on a task with second-row content', () => { + it('renders a 20px entry-condition diamond even when the task has execution details', () => { const { container } = renderTaskContent({ task: { hasEntryCondition: true }, taskExecution: { status: 'Completed', duration: '1m' }, }); const icon = getEntryIcon(container); - expect(icon).toHaveAttribute('width', '16'); - expect(icon).toHaveAttribute('height', '16'); + expect(icon).toHaveAttribute('width', '20'); + expect(icon).toHaveAttribute('height', '20'); }); }); @@ -237,14 +295,15 @@ describe('TaskContent - play button placement', () => { expect(screen.queryByTestId(playButtonId)).not.toBeInTheDocument(); }); - it('renders the small play button on the second row for executed tasks with row content', () => { + it('renders the play button for executed tasks alongside their execution details', () => { renderTaskContent({ onTaskPlay: vi.fn(async () => {}), taskExecution: { status: 'Completed', duration: '1m' }, }); const playButton = screen.getByTestId(playButtonId); - // small variant shrinks the icon via the button's descendant selector - expect(playButton.className).toContain('[&_svg]:size-3.5'); + expect(playButton).toBeInTheDocument(); + // Now rendered inline on the single row — no shrunk "small" variant. + expect(playButton.className).not.toContain('[&_svg]:size-3.5'); }); it('renders no play button for executed tasks without a play handler', () => { diff --git a/packages/apollo-react/src/canvas/components/StageNode/TaskContent.tsx b/packages/apollo-react/src/canvas/components/StageNode/TaskContent.tsx index 22bdf35eb..ccc8ff024 100644 --- a/packages/apollo-react/src/canvas/components/StageNode/TaskContent.tsx +++ b/packages/apollo-react/src/canvas/components/StageNode/TaskContent.tsx @@ -1,13 +1,13 @@ -import { Padding, Spacing } from '@uipath/apollo-core'; -import { Column, Row } from '@uipath/apollo-react/canvas/layouts'; +import { Spacing } from '@uipath/apollo-core'; +import { Row } from '@uipath/apollo-react/canvas/layouts'; import { Badge, Button, Spinner } from '@uipath/apollo-wind'; import debounce from 'debounce'; -import { CirclePlay } from 'lucide-react'; +import { CirclePlay, Redo2, RotateCcw } from 'lucide-react'; import { memo, useCallback, useMemo, useState } from 'react'; import { useSafeLingui } from '../../../i18n'; import { CanvasTooltip } from '../CanvasTooltip'; import { ExecutionStatusIcon } from '../ExecutionStatusIcon'; -import { StageTaskIcon, StageTaskRetryDuration } from './StageNode.styles'; +import { StageTaskIcon } from './StageNode.styles'; import type { StageTaskExecution, StageTaskItem } from './StageNode.types'; import { StageTaskEntryConditionIcon } from './StageTaskEntryConditionIcon'; import { useExecutionStatusLabel } from './useExecutionStatusLabel'; @@ -22,15 +22,7 @@ const ProcessCanvasIcon = () => ( ); const TaskPlayButton = memo( - ({ - taskId, - onTaskPlay, - small, - }: { - taskId: string; - onTaskPlay: (taskId: string) => Promise; - small?: boolean; - }) => { + ({ taskId, onTaskPlay }: { taskId: string; onTaskPlay: (taskId: string) => Promise }) => { const [playLoading, setPlayLoading] = useState(false); const { _ } = useSafeLingui(); const triggerTaskLabel = _({ id: 'stage-node.trigger-task', message: 'Trigger task' }); @@ -73,11 +65,7 @@ const TaskPlayButton = memo( onMouseDown={(e: React.MouseEvent) => e.stopPropagation()} onKeyDown={(e: React.KeyboardEvent) => e.stopPropagation()} aria-label={triggerTaskLabel} - className={ - small - ? 'task-menu-icon-button h-4 w-4 [&_svg]:size-3.5 rounded-sm' - : 'task-menu-icon-button h-4 w-4 rounded-sm' - } + className="task-menu-icon-button h-4 w-4 rounded-sm" style={{ color: 'var(--canvas-icon-default)', minWidth: 'unset', @@ -103,131 +91,131 @@ export const TaskContent = memo( const { _ } = useSafeLingui(); const getStatusName = useExecutionStatusLabel(); const hasExecutionStatus = !!taskExecution?.status; - const badgeText = useMemo(() => { - if (!taskExecution?.badge) { - return undefined; - } - if (taskExecution.retryCount && taskExecution.retryCount > 1) { - if (taskExecution.status === 'InProgress') { - return _({ - id: 'stage-node.task-badge.running-again', - message: 'Running again', + // Runs and rework collapse to two compact amber icon-chips (per design): a "runs" chip + // (↱ + total-run count) driven by retryCount, and a "rework" chip (↺) driven by the + // rework duration. The localised phrasing lives in each chip's tooltip so the row stays + // on one line and still reads for screen readers. retryCount is the number of re-runs, so + // the total-run count shown is retryCount + 1. + const retries = taskExecution?.retryCount ?? 0; + const hasRuns = retries > 0; + const totalRuns = retries + 1; + const runsTooltip = + taskExecution?.status === 'InProgress' + ? _({ id: 'stage-node.task-badge.running-again', message: 'Running again' }) + : _({ + id: 'stage-node.task-badge.ran-n-times', + message: '{count, plural, one {Ran # time} other {Ran # times}}', + values: { count: totalRuns }, }); - } - return _({ - id: 'stage-node.task-badge.ran-n-times', - message: '{count, plural, one {Ran # time} other {Ran # times}}', - values: { count: taskExecution.retryCount }, - }); - } - return taskExecution.badge; - }, [taskExecution?.badge, taskExecution?.retryCount, taskExecution?.status, _]); - const hasSecondRowContent = - taskExecution && (taskExecution.duration || taskExecution.retryDuration || badgeText); - const showTaskPlayButton = onTaskPlay && hasExecutionStatus; + const hasRework = !!taskExecution?.retryDuration; + const reworkTooltip = _({ + id: 'stage-node.task-badge.rework-time', + message: 'Reworked (+{duration})', + values: { duration: taskExecution?.retryDuration ?? '' }, + }); const taskStatusFallbackName = hasExecutionStatus ? getStatusName(taskExecution?.status) : ''; const taskStatusTooltip = taskExecution?.message || taskStatusFallbackName; - const durationLabel = useMemo( - () => - taskExecution?.duration ? ( - {taskExecution.duration} - ) : null, - [taskExecution?.duration] - ); + // Ad hoc tasks are by definition not required, so never show the "*" marker on them. + const showRequiredMarker = task.isRequired && !task.isAdhoc && task.taskGroupType !== 'adhoc'; + const durationLabel = taskExecution?.duration ? ( + {taskExecution.duration} + ) : null; return ( - - - {/* disable tooltip when dragging to avoid tooltip flickering */} - + {task.icon ?? } + - {task.icon ?? } - - - - {task.label} + + {task.label} + + {showRequiredMarker && ( + + {'*'} - {task.isRequired && ( - - {'*'} - - )} - - - - - {hasExecutionStatus && ( - - - - )} - {!hasSecondRowContent && } - {showTaskPlayButton && !hasSecondRowContent && ( - - )} - {onTaskPlay && !hasExecutionStatus && ( - - )} - - - {taskExecution && hasSecondRowContent && ( - - - {durationLabel && - (taskExecution?.durationTooltip ? ( - - {durationLabel} - - ) : ( - durationLabel - ))} - {taskExecution?.retryDuration && ( - - {`(+${taskExecution.retryDuration})`} - )} - - {badgeText && {badgeText}} - - {showTaskPlayButton && ( - - )} - - - )} - + + + + {durationLabel && + (taskExecution?.durationTooltip ? ( + + {durationLabel} + + ) : ( + durationLabel + ))} + {hasRuns && ( + + + + {totalRuns} + + + )} + {hasRework && ( + + + + + + )} + + {hasExecutionStatus && ( + + + + )} + {onTaskPlay && } + + ); } );