Skip to content

Commit bbb2a42

Browse files
fix(apollo-react): disable task three-dot menu while task is loading [MST-8171]
1 parent 90f6e3c commit bbb2a42

10 files changed

Lines changed: 288 additions & 34 deletions

File tree

packages/apollo-react/src/canvas/components/StageNode/AdhocTask.test.tsx

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,4 +293,74 @@ describe('AdhocTaskItem', () => {
293293
});
294294
});
295295
});
296+
297+
describe('Task Loading State', () => {
298+
it('disables menu button when isTaskLoading is true', () => {
299+
const onRemove = vi.fn();
300+
const menuItems = createMenuItems(onRemove);
301+
302+
render(
303+
<AdhocTaskItem
304+
{...defaultProps}
305+
getContextMenuItems={() => menuItems}
306+
isTaskLoading={true}
307+
/>
308+
);
309+
310+
const menuButton = screen.getByTestId('stage-task-menu-adhoc-1');
311+
expect(menuButton).toBeDisabled();
312+
});
313+
314+
it('does not disable menu button when isTaskLoading is false', () => {
315+
const onRemove = vi.fn();
316+
const menuItems = createMenuItems(onRemove);
317+
318+
render(
319+
<AdhocTaskItem
320+
{...defaultProps}
321+
getContextMenuItems={() => menuItems}
322+
isTaskLoading={false}
323+
/>
324+
);
325+
326+
const menuButton = screen.getByTestId('stage-task-menu-adhoc-1');
327+
expect(menuButton).not.toBeDisabled();
328+
});
329+
330+
it('does not open menu when clicking a disabled menu button', () => {
331+
const onRemove = vi.fn();
332+
const menuItems = createMenuItems(onRemove);
333+
334+
render(
335+
<AdhocTaskItem
336+
{...defaultProps}
337+
getContextMenuItems={() => menuItems}
338+
isTaskLoading={true}
339+
/>
340+
);
341+
342+
const menuButton = screen.getByTestId('stage-task-menu-adhoc-1');
343+
expect(menuButton).toBeDisabled();
344+
expect(screen.queryByText('Replace task')).not.toBeInTheDocument();
345+
});
346+
347+
it('does not open menu on right-click when isTaskLoading is true', async () => {
348+
const user = userEvent.setup();
349+
const onRemove = vi.fn();
350+
const menuItems = createMenuItems(onRemove);
351+
352+
render(
353+
<AdhocTaskItem
354+
{...defaultProps}
355+
getContextMenuItems={() => menuItems}
356+
isTaskLoading={true}
357+
/>
358+
);
359+
360+
const task = screen.getByTestId('stage-task-adhoc-1');
361+
await user.pointer({ keys: '[MouseRight]', target: task });
362+
363+
expect(screen.queryByText('Replace task')).not.toBeInTheDocument();
364+
});
365+
});
296366
});

packages/apollo-react/src/canvas/components/StageNode/AdhocTask.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ interface AdhocTaskItemProps {
7171
getContextMenuItems?: () => NodeMenuItem[];
7272
onTaskClick: (e: React.MouseEvent, taskId: string) => void;
7373
onTaskPlay?: (taskId: string) => Promise<void>;
74+
isTaskLoading?: boolean;
7475
}
7576

7677
const AdhocTaskItemComponent = ({
@@ -80,6 +81,7 @@ const AdhocTaskItemComponent = ({
8081
getContextMenuItems,
8182
onTaskClick,
8283
onTaskPlay,
84+
isTaskLoading,
8385
}: AdhocTaskItemProps) => {
8486
const [isMenuOpen, setIsMenuOpen] = useState(false);
8587
const taskRef = useRef<HTMLDivElement>(null);
@@ -108,7 +110,7 @@ const AdhocTaskItemComponent = ({
108110
selected={isSelected}
109111
status={taskExecution?.status}
110112
onClick={handleClick}
111-
{...(getContextMenuItems && { onContextMenu: handleContextMenu })}
113+
{...(getContextMenuItems && !isTaskLoading && { onContextMenu: handleContextMenu })}
112114
>
113115
<TaskContent task={task} taskExecution={taskExecution} />
114116
{onTaskPlay && <AdhocTaskPlayButton taskId={task.id} onTaskPlay={onTaskPlay} />}
@@ -119,6 +121,7 @@ const AdhocTaskItemComponent = ({
119121
getContextMenuItems={getContextMenuItems}
120122
onMenuOpenChange={handleMenuOpenChange}
121123
taskRef={taskRef}
124+
disabled={isTaskLoading}
122125
/>
123126
)}
124127
</StageTask>

packages/apollo-react/src/canvas/components/StageNode/DraggableTask.test.tsx

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,76 @@ describe('DraggableTask', () => {
346346
});
347347
});
348348

349+
describe('Task Loading State', () => {
350+
it('disables menu button when isTaskLoading is true', () => {
351+
const onRemove = vi.fn();
352+
const menuItems = createMenuItems(onRemove);
353+
354+
render(
355+
<DraggableTask
356+
{...defaultProps}
357+
getContextMenuItems={() => menuItems}
358+
isTaskLoading={true}
359+
/>
360+
);
361+
362+
const menuButton = screen.getByTestId('stage-task-menu-task-1');
363+
expect(menuButton).toBeDisabled();
364+
});
365+
366+
it('does not disable menu button when isTaskLoading is false', () => {
367+
const onRemove = vi.fn();
368+
const menuItems = createMenuItems(onRemove);
369+
370+
render(
371+
<DraggableTask
372+
{...defaultProps}
373+
getContextMenuItems={() => menuItems}
374+
isTaskLoading={false}
375+
/>
376+
);
377+
378+
const menuButton = screen.getByTestId('stage-task-menu-task-1');
379+
expect(menuButton).not.toBeDisabled();
380+
});
381+
382+
it('does not open menu when clicking a disabled menu button', () => {
383+
const onRemove = vi.fn();
384+
const menuItems = createMenuItems(onRemove);
385+
386+
render(
387+
<DraggableTask
388+
{...defaultProps}
389+
getContextMenuItems={() => menuItems}
390+
isTaskLoading={true}
391+
/>
392+
);
393+
394+
const menuButton = screen.getByTestId('stage-task-menu-task-1');
395+
expect(menuButton).toBeDisabled();
396+
expect(screen.queryByText('Move Up')).not.toBeInTheDocument();
397+
});
398+
399+
it('does not open menu on right-click when isTaskLoading is true', async () => {
400+
const user = userEvent.setup();
401+
const onRemove = vi.fn();
402+
const menuItems = createMenuItems(onRemove);
403+
404+
render(
405+
<DraggableTask
406+
{...defaultProps}
407+
getContextMenuItems={() => menuItems}
408+
isTaskLoading={true}
409+
/>
410+
);
411+
412+
const task = screen.getByTestId('stage-task-task-1');
413+
await user.pointer({ keys: '[MouseRight]', target: task });
414+
415+
expect(screen.queryByText('Move Up')).not.toBeInTheDocument();
416+
});
417+
});
418+
349419
describe('Task Rendering', () => {
350420
it('renders task label', () => {
351421
render(<DraggableTask {...defaultProps} />);

packages/apollo-react/src/canvas/components/StageNode/DraggableTask.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ const DraggableTaskComponent = ({
131131
onTaskClick,
132132
isDragDisabled,
133133
projectedDepth,
134+
isTaskLoading,
134135
}: DraggableTaskProps) => {
135136
const [isMenuOpen, setIsMenuOpen] = useState(false);
136137
const zoom = useStore((s) => s.transform[2]);
@@ -208,7 +209,7 @@ const DraggableTaskComponent = ({
208209
isParallel={isParallel}
209210
isDragEnabled={!isDragDisabled}
210211
onClick={handleClick}
211-
{...(getContextMenuItems && { onContextMenu: handleContextMenu })}
212+
{...(getContextMenuItems && !isTaskLoading && { onContextMenu: handleContextMenu })}
212213
>
213214
<TaskContent task={task} taskExecution={taskExecution} />
214215

@@ -233,6 +234,7 @@ const DraggableTaskComponent = ({
233234
getContextMenuItems={handleGetContextMenuItems}
234235
onMenuOpenChange={handleMenuOpenChange}
235236
taskRef={taskRef}
237+
disabled={isTaskLoading}
236238
/>
237239
)}
238240
</StageTask>

packages/apollo-react/src/canvas/components/StageNode/DraggableTask.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,5 @@ export interface DraggableTaskProps {
1919
onTaskPlay?: (taskId: string) => Promise<void>;
2020
isDragDisabled?: boolean;
2121
projectedDepth?: number;
22+
isTaskLoading?: boolean;
2223
}

packages/apollo-react/src/canvas/components/StageNode/StageNode.stories.tsx

Lines changed: 106 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1443,18 +1443,57 @@ const AddTaskLoadingStory = () => {
14431443
const setNodesRef = useRef<React.Dispatch<React.SetStateAction<Node[]>>>(null!);
14441444

14451445
// Inject per-node handlers — simulates loading state on add-task:
1446-
// 1. Set addTaskLoading=true so the + button is disabled
1447-
// 2. After 2s, set addTaskLoading=false to re-enable it
1446+
// 1. Set loadingTaskIds with a placeholder ID so the + button is disabled
1447+
// 2. After 2s, clear loadingTaskIds to re-enable it
14481448
const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
14491449

1450-
const handleAddTaskFromToolbox = useCallback((nodeId: string, _taskItem: ListItem) => {
1450+
const handleAddTaskFromToolbox = useCallback((nodeId: string, taskItem: ListItem) => {
14511451
clearTimeout(timeoutRef.current);
1452+
const newTaskId = `task-${Date.now()}`;
1453+
// Add the task to the stage and mark it as loading
14521454
setNodesRef.current((nds) =>
1453-
nds.map((n) => (n.id === nodeId ? { ...n, data: { ...n.data, addTaskLoading: true } } : n))
1455+
nds.map((n) => {
1456+
if (n.id !== nodeId) return n;
1457+
const data = n.data as Record<string, any>;
1458+
const currentTasks: StageTaskItem[][] = data.stageDetails?.tasks ?? [];
1459+
const newTask: StageTaskItem = { id: newTaskId, label: taskItem.name };
1460+
const currentExecution = data.execution ?? {
1461+
stageStatus: { status: undefined },
1462+
taskStatus: {},
1463+
};
1464+
return {
1465+
...n,
1466+
data: {
1467+
...data,
1468+
stageDetails: { ...data.stageDetails, tasks: [...currentTasks, [newTask]] },
1469+
loadingTaskIds: new Set([...((data.loadingTaskIds as Set<string>) ?? []), newTaskId]),
1470+
execution: {
1471+
...currentExecution,
1472+
taskStatus: { ...currentExecution.taskStatus, [newTaskId]: { status: 'InProgress' } },
1473+
},
1474+
},
1475+
};
1476+
})
14541477
);
1478+
// After 2s, clear loading state and execution status
14551479
timeoutRef.current = setTimeout(() => {
14561480
setNodesRef.current((nds) =>
1457-
nds.map((n) => (n.id === nodeId ? { ...n, data: { ...n.data, addTaskLoading: false } } : n))
1481+
nds.map((n) => {
1482+
if (n.id !== nodeId) return n;
1483+
const data = n.data as Record<string, any>;
1484+
const { [newTaskId]: _, ...remainingTaskStatus } = data.execution?.taskStatus ?? {};
1485+
return {
1486+
...n,
1487+
data: {
1488+
...data,
1489+
loadingTaskIds: new Set(),
1490+
execution: {
1491+
...data.execution,
1492+
taskStatus: remainingTaskStatus,
1493+
},
1494+
},
1495+
};
1496+
})
14581497
);
14591498
}, 2000);
14601499
}, []);
@@ -1471,11 +1510,12 @@ const AddTaskLoadingStory = () => {
14711510
label: 'Loading (+ disabled for 3s)',
14721511
tasks: [],
14731512
},
1474-
addTaskLoading: true,
1513+
loadingTaskIds: new Set(['loading-task']),
14751514
taskOptions: [] as ListItem[],
14761515
onAddTaskFromToolbox: (taskItem: ListItem) => {
14771516
handleAddTaskFromToolbox('loading-stage-empty', taskItem);
14781517
},
1518+
onTaskGroupModification: () => {},
14791519
},
14801520
},
14811521
{
@@ -1488,11 +1528,45 @@ const AddTaskLoadingStory = () => {
14881528
label: 'Async children (click +)',
14891529
tasks: [[{ id: 'task-1', label: 'Existing Task', icon: <VerificationIcon /> }]],
14901530
},
1491-
addTaskLoading: false,
1531+
loadingTaskIds: new Set(),
14921532
taskOptions: loadedTaskOptionsWithChildren,
14931533
onAddTaskFromToolbox: (taskItem: ListItem) => {
14941534
handleAddTaskFromToolbox('loading-stage-children', taskItem);
14951535
},
1536+
onTaskGroupModification: () => {},
1537+
},
1538+
},
1539+
{
1540+
id: 'loading-stage-tasks',
1541+
type: 'stage',
1542+
position: { x: 752, y: 96 },
1543+
width: 304,
1544+
data: {
1545+
stageDetails: {
1546+
label: 'Task loading (3-dot disabled)',
1547+
tasks: [
1548+
[{ id: 'loading-task-1', label: 'Loading Task (3-dot disabled)' }],
1549+
[
1550+
{
1551+
id: 'ready-task-1',
1552+
label: 'Ready Task (3-dot enabled)',
1553+
icon: <VerificationIcon />,
1554+
},
1555+
],
1556+
],
1557+
},
1558+
loadingTaskIds: new Set(['loading-task-1']),
1559+
execution: {
1560+
stageStatus: { status: undefined },
1561+
taskStatus: {
1562+
'loading-task-1': { status: 'InProgress' },
1563+
},
1564+
},
1565+
taskOptions: loadedTaskOptionsWithChildren,
1566+
onAddTaskFromToolbox: (taskItem: ListItem) => {
1567+
handleAddTaskFromToolbox('loading-stage-tasks', taskItem);
1568+
},
1569+
onTaskGroupModification: () => {},
14961570
},
14971571
},
14981572
],
@@ -1513,7 +1587,7 @@ const AddTaskLoadingStory = () => {
15131587
...node,
15141588
data: {
15151589
...node.data,
1516-
addTaskLoading: false,
1590+
loadingTaskIds: new Set(),
15171591
taskOptions: loadedTaskOptionsWithChildren,
15181592
},
15191593
}
@@ -1524,6 +1598,30 @@ const AddTaskLoadingStory = () => {
15241598
return () => clearTimeout(timeout);
15251599
}, [setNodes]);
15261600

1601+
// Simulate per-task loading — after 5 seconds, task finishes loading and 3-dot becomes enabled
1602+
useEffect(() => {
1603+
const timeout = setTimeout(() => {
1604+
setNodes((nds) =>
1605+
nds.map((node) =>
1606+
node.id === 'loading-stage-tasks'
1607+
? {
1608+
...node,
1609+
data: {
1610+
...node.data,
1611+
loadingTaskIds: new Set(),
1612+
execution: {
1613+
stageStatus: { status: undefined },
1614+
taskStatus: {},
1615+
},
1616+
},
1617+
}
1618+
: node
1619+
)
1620+
);
1621+
}, 5000);
1622+
return () => clearTimeout(timeout);
1623+
}, [setNodes]);
1624+
15271625
useEffect(() => {
15281626
return () => clearTimeout(timeoutRef.current);
15291627
}, []);

0 commit comments

Comments
 (0)