Skip to content

Commit 5610b2a

Browse files
committed
feat(frontend): fill task when artifact is hovered over
1 parent 8c20a23 commit 5610b2a

File tree

10 files changed

+301
-100
lines changed

10 files changed

+301
-100
lines changed

frontend/relay-workflows-lib/lib/components/SingleWorkflowView.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useMemo, useState } from "react";
1+
import { useCallback, useEffect, useMemo, useState } from "react";
22
import { useLazyLoadQuery } from "react-relay";
33
import { TaskInfo } from "workflows-lib/lib/components/workflow/TaskInfo";
44
import { Artifact, Task, TaskNode } from "workflows-lib/lib/types";
@@ -30,6 +30,7 @@ export default function SingleWorkflowView({
3030
const [outputTasks, setOutputTasks] = useState<string[]>([]);
3131
const fetchedTasks = useFetchedTasks(data, visit, workflowName);
3232
const [selectedTasks, setSelectedTasks] = useSelectedTasks();
33+
const [filledTaskName, setFilledTaskName] = useState<string | null>(null)
3334

3435
const taskTree = useMemo(() => buildTaskTree(fetchedTasks), [fetchedTasks]);
3536

@@ -41,6 +42,10 @@ export default function SingleWorkflowView({
4142
setSelectedTasks([]);
4243
};
4344

45+
const onArtifactHover = useCallback((artifact: Artifact | null) => {
46+
setFilledTaskName(artifact ? artifact.parentTask : null)
47+
}, [setFilledTaskName])
48+
4449
useEffect(() => {
4550
setSelectedTasks(tasknames ? tasknames : []);
4651
}, [tasknames, setSelectedTasks]);
@@ -121,11 +126,12 @@ export default function SingleWorkflowView({
121126
workflowName={data.workflow.name}
122127
visit={data.workflow.visit}
123128
workflowLink
129+
filledTaskName={filledTaskName}
124130
expanded={true}
125131
/>
126132
</Box>
127133
</Box>
128-
{tasknames && <TaskInfo artifactList={artifactList} />}
134+
{tasknames && <TaskInfo artifactList={artifactList} onArtifactHover={onArtifactHover}/>}
129135
<WorkflowInfo workflow={data.workflow} />
130136
</>
131137
);

frontend/relay-workflows-lib/lib/components/WorkflowRelay.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ interface WorkflowRelayProps {
107107
visit: Visit;
108108
workflowName: string;
109109
workflowLink?: boolean;
110+
filledTaskName?: string | null;
110111
expanded?: boolean;
111112
onChange?: () => void;
112113
}
@@ -115,6 +116,7 @@ const WorkflowRelay: React.FC<WorkflowRelayProps> = ({
115116
visit,
116117
workflowName,
117118
workflowLink,
119+
filledTaskName,
118120
expanded,
119121
onChange,
120122
}) => {
@@ -208,8 +210,9 @@ const WorkflowRelay: React.FC<WorkflowRelayProps> = ({
208210
<TasksFlow
209211
workflowName={workflowName}
210212
tasks={fetchedTasks}
211-
highlightedTaskNames={selectedTasks}
212213
onNavigate={onNavigate}
214+
highlightedTaskNames={selectedTasks}
215+
filledTaskName={filledTaskName}
213216
></TasksFlow>
214217
</ResizableBox>
215218
</WorkflowAccordion>

frontend/workflows-lib/lib/components/workflow/ArtifactFilteredList.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@ import type { Artifact } from "workflows-lib";
1515

1616
interface ArtifactFilteredListProps {
1717
artifactList: Artifact[];
18+
onArtifactHover?: (artifactName: Artifact | null) => void;
1819
}
1920

2021
export const ArtifactFilteredList: React.FC<ArtifactFilteredListProps> = ({
2122
artifactList,
23+
onArtifactHover,
2224
}) => {
2325
const [artifactFilter, setArtifactFilter] = useState<string>("all");
2426

@@ -103,6 +105,8 @@ export const ArtifactFilteredList: React.FC<ArtifactFilteredListProps> = ({
103105
style={{ cursor: "pointer" }}
104106
>
105107
<TableCell
108+
onMouseEnter={() => onArtifactHover?.(artifact)}
109+
onMouseLeave={() => onArtifactHover?.(null)}
106110
sx={{
107111
textOverflow: "ellipsis",
108112
overflow: "hidden",
@@ -114,6 +118,8 @@ export const ArtifactFilteredList: React.FC<ArtifactFilteredListProps> = ({
114118
{artifact.name}
115119
</TableCell>
116120
<TableCell
121+
onMouseEnter={() => onArtifactHover?.(artifact)}
122+
onMouseLeave={() => onArtifactHover?.(null)}
117123
sx={{
118124
textOverflow: "ellipsis",
119125
overflow: "hidden",

frontend/workflows-lib/lib/components/workflow/TaskInfo.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ import { ImageInfo, ScrollableImages } from "./ScrollableImages";
1212
import { useMemo } from "react";
1313

1414
interface TaskInfoProps {
15-
artifactList: Artifact[];
15+
artifactList: Artifact[], onArtifactHover?: (artifactName: Artifact | null) => void;
1616
}
1717

18-
export const TaskInfo: React.FC<TaskInfoProps> = ({ artifactList }) => {
18+
export const TaskInfo: React.FC<TaskInfoProps> = ({ artifactList, onArtifactHover }) => {
1919
const imageArtifactsInfos: ImageInfo[] = useMemo(() => {
2020
return artifactList
2121
.filter((artifact) => artifact.mimeType === "image/png")
@@ -45,7 +45,10 @@ export const TaskInfo: React.FC<TaskInfoProps> = ({ artifactList }) => {
4545
}}
4646
>
4747
<Box sx={{ flex: 1, minWidth: "300px" }}>
48-
<ArtifactFilteredList artifactList={artifactList} />
48+
<ArtifactFilteredList
49+
artifactList={artifactList}
50+
onArtifactHover={onArtifactHover}
51+
/>
4952
</Box>
5053

5154
<Box

frontend/workflows-lib/lib/components/workflow/TasksFlow.tsx

Lines changed: 51 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,20 @@ import React, {
55
useState,
66
useMemo,
77
} from "react";
8-
import { Box, IconButton, Tooltip } from "@mui/material";
8+
import { Box, debounce, IconButton, Tooltip } from "@mui/material";
99
import { AspectRatio } from "@mui/icons-material";
1010
import {
1111
ReactFlow,
1212
ReactFlowInstance,
1313
Viewport,
1414
getNodesBounds,
1515
} from "@xyflow/react";
16+
import type { Node } from "@xyflow/react";
1617
import "@xyflow/react/dist/style.css";
1718
import TaskFlowNode, { TaskFlowNodeData } from "./TasksFlowNode";
1819
import TasksTable from "./TasksTable";
1920
import {
21+
addHighlightsAndFills,
2022
applyDagreLayout,
2123
buildTaskTree,
2224
generateNodesAndEdges,
@@ -29,39 +31,37 @@ const defaultViewport = { x: 0, y: 0, zoom: 1.5 };
2931
interface TasksFlowProps {
3032
workflowName: string;
3133
tasks: Task[];
32-
highlightedTaskNames?: string[];
3334
onNavigate: (s: string) => void;
35+
highlightedTaskNames?: string[];
36+
filledTaskName?: string | null;
3437
isDynamic?: boolean;
3538
}
3639

3740
const TasksFlow: React.FC<TasksFlowProps> = ({
3841
workflowName,
3942
tasks,
40-
highlightedTaskNames,
4143
onNavigate,
44+
highlightedTaskNames,
45+
filledTaskName,
4246
isDynamic,
4347
}) => {
44-
const previousTaskCount = useRef<number>(tasks.length);
45-
const debounceTimeout = useRef<NodeJS.Timeout | null>(null);
46-
const nodeTypes = useMemo(() => ({
47-
custom: (props: { data: TaskFlowNodeData }) => (
48-
<TaskFlowNode onNavigate={onNavigate} {...props} />
49-
),
50-
}), [onNavigate]);
51-
52-
const taskTree = useMemo(() => buildTaskTree(tasks), [tasks]);
53-
const { nodes, edges } = useMemo(
54-
() => generateNodesAndEdges(taskTree, highlightedTaskNames),
55-
[taskTree, highlightedTaskNames],
56-
);
57-
const { nodes: layoutedNodes, edges: layoutedEdges } = useMemo(
58-
() => applyDagreLayout(nodes, edges),
59-
[nodes, edges],
60-
);
61-
6248
const reactFlowInstance = useRef<ReactFlowInstance | null>(null);
6349
const containerRef = useRef<HTMLDivElement | null>(null);
6450
const [isOverflow, setIsOverflow] = useState(false);
51+
const [nodesWithHighlights, setNodesWithHighlights] = useState<Node[] | null>(
52+
null
53+
);
54+
55+
const previousTaskCount = useRef<number>(tasks.length);
56+
const debounceTimeout = useRef<NodeJS.Timeout | null>(null);
57+
const nodeTypes = useMemo(
58+
() => ({
59+
custom: (props: { data: TaskFlowNodeData }) => (
60+
<TaskFlowNode onNavigate={onNavigate} {...props} />
61+
),
62+
}),
63+
[onNavigate]
64+
);
6565

6666
const { saveViewport, loadViewport, clearViewport } =
6767
usePersistentViewport(workflowName);
@@ -70,11 +70,37 @@ const TasksFlow: React.FC<TasksFlowProps> = ({
7070
(viewport: Viewport) => {
7171
saveViewport(viewport);
7272
},
73-
[saveViewport],
73+
[saveViewport]
7474
);
7575

76-
const hasInitialized = useRef(false);
76+
const taskTree = useMemo(() => buildTaskTree(tasks), [tasks]);
77+
const { nodes, edges } = useMemo(
78+
() => generateNodesAndEdges(taskTree),
79+
[taskTree]
80+
);
81+
82+
const { nodes: layoutedNodes, edges: layoutedEdges } = useMemo(
83+
() => applyDagreLayout(nodes, edges),
84+
[nodes, edges]
85+
);
86+
87+
useEffect(() => {
88+
const updateHighlights = debounce(() => {
89+
const highlightedNodes = addHighlightsAndFills(
90+
layoutedNodes,
91+
highlightedTaskNames,
92+
filledTaskName
93+
);
94+
setNodesWithHighlights(highlightedNodes);
95+
}, 20);
96+
97+
updateHighlights();
98+
return () => {
99+
updateHighlights.clear();
100+
};
101+
}, [layoutedNodes, highlightedTaskNames, filledTaskName]);
77102

103+
const hasInitialized = useRef(false);
78104
const onInit = useCallback(
79105
(instance: ReactFlowInstance) => {
80106
reactFlowInstance.current = instance;
@@ -88,7 +114,7 @@ const TasksFlow: React.FC<TasksFlowProps> = ({
88114
hasInitialized.current = true;
89115
}
90116
},
91-
[loadViewport],
117+
[loadViewport]
92118
);
93119

94120
const resetView = () => {
@@ -121,7 +147,6 @@ const TasksFlow: React.FC<TasksFlowProps> = ({
121147
setIsOverflow(boundingBox.width > width || boundingBox.height > height);
122148
}
123149
};
124-
125150
const resizeObserver = new ResizeObserver(handleResizeAndOverflow);
126151
const currentContainerRef = containerRef.current;
127152

@@ -163,7 +188,7 @@ const TasksFlow: React.FC<TasksFlowProps> = ({
163188
<ReactFlow
164189
onInit={onInit}
165190
onViewportChange={onViewportChangeEnd}
166-
nodes={layoutedNodes}
191+
nodes={nodesWithHighlights ? nodesWithHighlights : layoutedNodes}
167192
edges={layoutedEdges}
168193
nodeTypes={nodeTypes}
169194
nodesDraggable={false}

frontend/workflows-lib/lib/components/workflow/TasksFlowNode.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export interface TaskFlowNodeData {
1212
workflow: string;
1313
instrumentSession: Visit;
1414
highlighted: boolean;
15+
filled: boolean;
1516
}
1617

1718
interface TaskFlowNodeProps {
@@ -24,7 +25,8 @@ const TaskFlowNode: React.FC<TaskFlowNodeProps> = ({ data, onNavigate }) => {
2425
const handleOpenTaskPage = (e: React.MouseEvent) => {
2526
const instrumentSessionId = visitToText(data.instrumentSession);
2627
onNavigate(
27-
`/workflows/${instrumentSessionId}/${data.workflow}/${data.label}/`, e
28+
`/workflows/${instrumentSessionId}/${data.workflow}/${data.label}/`,
29+
e
2830
);
2931
};
3032

@@ -41,6 +43,7 @@ const TaskFlowNode: React.FC<TaskFlowNodeProps> = ({ data, onNavigate }) => {
4143
border: data.highlighted ? "1px solid #ff9c1a" : "1px solid #ccc",
4244
boxShadow: data.highlighted ? "0 0 10px #ff9c1a" : theme.shadows[3],
4345
transition: "all 0.3s ease-in-out",
46+
backgroundColor: data.filled ? "rgb(255, 232, 202)" : undefined,
4447
}}
4548
>
4649
<Handle

frontend/workflows-lib/lib/utils/tasksFlowUtils.ts

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { Task, TaskNode } from "../types";
55

66
export function applyDagreLayout(
77
nodes: Node[],
8-
edges: Edge[],
8+
edges: Edge[]
99
): { nodes: Node[]; edges: Edge[] } {
1010
const graph = new dagre.graphlib.Graph();
1111

@@ -58,33 +58,33 @@ export function buildTaskTree(tasks: Task[]): TaskNode[] {
5858

5959
export function isRedundantStep(task: TaskNode) {
6060
return (
61-
(task.stepType === "DAG" || task.stepType === "Steps") && (!task.depends || task.depends.length === 0)
61+
(task.stepType === "DAG" || task.stepType === "Steps") &&
62+
(!task.depends || task.depends.length === 0)
6263
);
6364
}
6465

6566
export function extractStepNumber(nodeName: string): string {
66-
const number = parseInt(nodeName.slice(1,-1)) + 1;
67+
const number = parseInt(nodeName.slice(1, -1)) + 1;
6768
return "Step " + number.toString();
6869
}
6970

70-
export function generateNodesAndEdges(
71-
taskNodes: TaskNode[],
72-
highlightedTaskNames?: string[],
73-
): {
71+
export function generateNodesAndEdges(taskNodes: TaskNode[]): {
7472
nodes: Node[];
7573
edges: Edge[];
7674
} {
7775
const nodes: Node[] = [];
7876
const edges: Edge[] = [];
79-
8077
const traverse = (tasks: TaskNode[], parents: string[] = []) => {
8178
const sortedTasks = [...tasks].sort((a, b) => a.name.localeCompare(b.name));
8279
sortedTasks.forEach((task) => {
8380
if (
8481
!nodes.some((existingNode) => existingNode.id === task.id) &&
8582
!isRedundantStep(task)
8683
) {
87-
const taskName: string = task.stepType === "StepGroup" ? extractStepNumber(task.name) : task.name
84+
const taskName: string =
85+
task.stepType === "StepGroup"
86+
? extractStepNumber(task.name)
87+
: task.name;
8888
nodes.push({
8989
id: task.id,
9090
type: "custom",
@@ -94,7 +94,8 @@ export function generateNodesAndEdges(
9494
details: task.artifacts,
9595
workflow: task.workflow,
9696
instrumentSession: task.instrumentSession,
97-
highlighted: highlightedTaskNames?.includes(task.name) ?? false,
97+
highlighted: false,
98+
filled: false,
9899
},
99100
position: { x: 0, y: 0 },
100101
});
@@ -122,6 +123,24 @@ export function generateNodesAndEdges(
122123
return { nodes, edges };
123124
}
124125

126+
export function addHighlightsAndFills(
127+
nodes: Node[],
128+
highlightedTaskNames?: string[],
129+
filledTaskName?: string | null
130+
): Node[] {
131+
return nodes.map((node) => {
132+
return {
133+
...node,
134+
data: {
135+
...node.data,
136+
highlighted:
137+
highlightedTaskNames?.includes(String(node.data.label)) ?? false,
138+
filled: filledTaskName === node.data.label,
139+
},
140+
};
141+
});
142+
}
143+
125144
export function usePersistentViewport(workflowName: string) {
126145
const viewport_key = `${workflowName}Viewport`;
127146
const saveViewport = useCallback(
@@ -130,7 +149,7 @@ export function usePersistentViewport(workflowName: string) {
130149
sessionStorage.setItem(viewport_key, JSON.stringify(viewport));
131150
}
132151
},
133-
[viewport_key],
152+
[viewport_key]
134153
);
135154

136155
const loadViewport = useCallback((): Viewport | null => {

0 commit comments

Comments
 (0)