From 9d5bd21a3c5636da73a53085e2bb2968b6e5ef5f Mon Sep 17 00:00:00 2001 From: Thomas Binu Thomas Date: Fri, 14 Nov 2025 10:46:53 +0000 Subject: [PATCH 1/2] refactor(frontend): refactor frontend for new linting rules --- .../lib/components/TasksFlow.tsx | 2 +- .../lib/components/WorkflowsContent.tsx | 8 +- .../lib/utils/workflowRelayUtils.ts | 52 ++++--- .../lib/views/BaseSingleWorkflowView.tsx | 46 +++--- .../lib/views/WorkflowsListView.tsx | 10 +- .../template/controls/ScanRangeInput.tsx | 143 +++++++++--------- .../jsonforms/JsonFormsScanRangeRenderer.tsx | 15 +- .../components/workflow/ScrollableImages.tsx | 2 +- .../lib/components/workflow/TaskInfo.tsx | 9 +- 9 files changed, 146 insertions(+), 141 deletions(-) diff --git a/frontend/relay-workflows-lib/lib/components/TasksFlow.tsx b/frontend/relay-workflows-lib/lib/components/TasksFlow.tsx index e5e77bd03..f2379240e 100644 --- a/frontend/relay-workflows-lib/lib/components/TasksFlow.tsx +++ b/frontend/relay-workflows-lib/lib/components/TasksFlow.tsx @@ -204,7 +204,7 @@ const TasksFlow: React.FC = ({ panOnDrag={true} preventScrolling={false} defaultViewport={defaultViewport} - fitView={false} + fitView={true} style={{ width: "100%", height: "100%", overflow: "auto" }} /> )} diff --git a/frontend/relay-workflows-lib/lib/components/WorkflowsContent.tsx b/frontend/relay-workflows-lib/lib/components/WorkflowsContent.tsx index 187b694ee..86ce2abeb 100644 --- a/frontend/relay-workflows-lib/lib/components/WorkflowsContent.tsx +++ b/frontend/relay-workflows-lib/lib/components/WorkflowsContent.tsx @@ -38,7 +38,7 @@ interface WorkflowsContentProps { onLimitChange: (limit: number) => void; updatePageInfo: (hasNextPage: boolean, endCursor: string | null) => void; isPaginated: boolean; - setIsPaginated: (b: boolean) => void; + setIsPaginated: (isPaginated: boolean) => void; } export default function WorkflowsContent({ @@ -60,6 +60,7 @@ export default function WorkflowsContent({ const pageInfo = data.pageInfo; const fetchedWorkflows = data.nodes; const prevFetchedRef = useRef([]); + const isPaginatedRef = useRef(isPaginated); const [expandedWorkflows, setExpandedWorkflows] = useState>( new Set(), @@ -74,12 +75,13 @@ export default function WorkflowsContent({ const prevNames = prevFetchedRef.current; const fetchedChanged = JSON.stringify(currentNames) !== JSON.stringify(prevNames); - if (fetchedChanged && isPaginated) { + if (fetchedChanged && isPaginatedRef.current) { setTimeout(() => { + isPaginatedRef.current = false; setIsPaginated(false); }, 0); } - }, [isPaginated, fetchedWorkflows, setIsPaginated]); + }, [isPaginatedRef, fetchedWorkflows, setIsPaginated]); const handleToggleExpanded = (name: string) => { setExpandedWorkflows((prev) => { diff --git a/frontend/relay-workflows-lib/lib/utils/workflowRelayUtils.ts b/frontend/relay-workflows-lib/lib/utils/workflowRelayUtils.ts index aba96f6bf..c999bba68 100644 --- a/frontend/relay-workflows-lib/lib/utils/workflowRelayUtils.ts +++ b/frontend/relay-workflows-lib/lib/utils/workflowRelayUtils.ts @@ -1,10 +1,13 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo } from "react"; import { useSearchParams } from "react-router-dom"; import { Artifact, Task } from "workflows-lib"; import { isWorkflowWithTasks } from "../utils/coreUtils"; import { useFragment } from "react-relay"; import { WorkflowTasksFragment } from "../graphql/WorkflowTasksFragment"; -import { WorkflowTasksFragment$key } from "../graphql/__generated__/WorkflowTasksFragment.graphql"; +import { + WorkflowTasksFragment$data, + WorkflowTasksFragment$key, +} from "../graphql/__generated__/WorkflowTasksFragment.graphql"; export function updateSearchParamsWithTaskIds( updatedTaskIds: string[], @@ -51,32 +54,37 @@ export function useSelectedTaskIds(): [string[], (tasks: string[]) => void] { return [selectedTaskIds, setSelectedTaskIds]; } +function setFetchedTasks(data: WorkflowTasksFragment$data): Task[] { + if (data.status && isWorkflowWithTasks(data.status)) { + return data.status.tasks.map((task: Task) => ({ + id: task.id, + name: task.name, + status: task.status, + depends: [...(task.depends ?? [])], + artifacts: task.artifacts.map((artifact: Artifact) => ({ + ...artifact, + parentTask: task.name, + parentTaskId: task.id, + key: `${task.id}-${artifact.name}`, + })), + workflow: data.name, + instrumentSession: data.visit, + stepType: task.stepType, + })); + } + return []; +} + export function useFetchedTasks( fragmentRef: WorkflowTasksFragment$key | null, ): Task[] { - const [fetchedTasks, setFetchedTasks] = useState([]); const data = useFragment(WorkflowTasksFragment, fragmentRef); - useEffect(() => { - if (data && data.status && isWorkflowWithTasks(data.status)) { - setFetchedTasks( - data.status.tasks.map((task: Task) => ({ - id: task.id, - name: task.name, - status: task.status, - depends: [...(task.depends ?? [])], - artifacts: task.artifacts.map((artifact: Artifact) => ({ - ...artifact, - parentTask: task.name, - parentTaskId: task.id, - key: `${task.id}-${artifact.name}`, - })), - workflow: data.name, - instrumentSession: data.visit, - stepType: task.stepType, - })), - ); + const fetchedTasks: Task[] = useMemo(() => { + if (data == null) { + return []; } + return setFetchedTasks(data); }, [data]); return fetchedTasks; diff --git a/frontend/relay-workflows-lib/lib/views/BaseSingleWorkflowView.tsx b/frontend/relay-workflows-lib/lib/views/BaseSingleWorkflowView.tsx index 9e2690cd9..644771abf 100644 --- a/frontend/relay-workflows-lib/lib/views/BaseSingleWorkflowView.tsx +++ b/frontend/relay-workflows-lib/lib/views/BaseSingleWorkflowView.tsx @@ -34,8 +34,6 @@ export default function BaseSingleWorkflowView({ taskIds, fragmentRef, }: BaseSingleWorkflowViewProps) { - const [artifactList, setArtifactList] = useState([]); - const [outputTaskIds, setOutputTaskIds] = useState([]); const data = useFragment(BaseSingleWorkflowViewFragment, fragmentRef); const fetchedTasks = useFetchedTasks(data ?? null); const [selectedTaskIds, setSelectedTaskIds] = useSelectedTaskIds(); @@ -43,6 +41,26 @@ export default function BaseSingleWorkflowView({ const taskTree = useMemo(() => buildTaskTree(fetchedTasks), [fetchedTasks]); + const outputTaskIds: string[] = useMemo(() => { + const newOutputTaskIds: string[] = []; + const traverse = (tasks: TaskNode[]) => { + const sortedTasks = [...tasks].sort((a, b) => a.id.localeCompare(b.id)); + sortedTasks.forEach((taskNode) => { + if ( + taskNode.children && + taskNode.children.length === 0 && + !newOutputTaskIds.includes(taskNode.id) + ) { + newOutputTaskIds.push(taskNode.id); + } else if (taskNode.children && taskNode.children.length > 0) { + traverse(taskNode.children); + } + }); + }; + traverse(taskTree); + return newOutputTaskIds; + }, [taskTree]); + const handleSelectOutput = () => { setSelectedTaskIds(outputTaskIds); }; @@ -62,35 +80,15 @@ export default function BaseSingleWorkflowView({ setSelectedTaskIds(taskIds ?? []); }, [taskIds, setSelectedTaskIds]); - useEffect(() => { + const artifactList: Artifact[] = useMemo(() => { const filteredTasks = selectedTaskIds.length ? selectedTaskIds .map((id) => fetchedTasks.find((task) => task.id === id)) .filter((task): task is Task => !!task) : fetchedTasks; - setArtifactList(filteredTasks.flatMap((task) => task.artifacts)); + return filteredTasks.flatMap((task) => task.artifacts); }, [selectedTaskIds, fetchedTasks]); - useEffect(() => { - const newOutputTaskIds: string[] = []; - const traverse = (tasks: TaskNode[]) => { - const sortedTasks = [...tasks].sort((a, b) => a.id.localeCompare(b.id)); - sortedTasks.forEach((taskNode) => { - if ( - taskNode.children && - taskNode.children.length === 0 && - !newOutputTaskIds.includes(taskNode.id) - ) { - newOutputTaskIds.push(taskNode.id); - } else if (taskNode.children && taskNode.children.length > 0) { - traverse(taskNode.children); - } - }); - }; - traverse(taskTree); - setOutputTaskIds(newOutputTaskIds); - }, [taskTree]); - if (!data || !data.status) { return null; } diff --git a/frontend/relay-workflows-lib/lib/views/WorkflowsListView.tsx b/frontend/relay-workflows-lib/lib/views/WorkflowsListView.tsx index 8a99f58ae..42764e6b6 100644 --- a/frontend/relay-workflows-lib/lib/views/WorkflowsListView.tsx +++ b/frontend/relay-workflows-lib/lib/views/WorkflowsListView.tsx @@ -72,10 +72,14 @@ const WorkflowsListView: React.FC = ({ { fetchPolicy: "store-and-network" }, ); - const [isPaginated, setIsPaginated] = useState(false); + const isPaginated = useRef(false); const lastPage = useRef(currentPage); const lastLimit = useRef(selectedLimit); + const setIsPaginated = useCallback((value: boolean) => { + isPaginated.current = value; + }, []); + const load = useCallback(() => { if (visit) { loadQuery( @@ -98,7 +102,7 @@ const WorkflowsListView: React.FC = ({ currentPage !== lastPage.current || selectedLimit !== lastLimit.current ) { - setIsPaginated(true); + isPaginated.current = true; lastPage.current = currentPage; lastLimit.current = selectedLimit; } @@ -154,7 +158,7 @@ const WorkflowsListView: React.FC = ({ onPageChange={goToPage} onLimitChange={changeLimit} updatePageInfo={updatePageInfo} - isPaginated={isPaginated} + isPaginated={isPaginated.current} setIsPaginated={setIsPaginated} /> diff --git a/frontend/workflows-lib/lib/components/template/controls/ScanRangeInput.tsx b/frontend/workflows-lib/lib/components/template/controls/ScanRangeInput.tsx index ed4bf611a..ac9597e67 100644 --- a/frontend/workflows-lib/lib/components/template/controls/ScanRangeInput.tsx +++ b/frontend/workflows-lib/lib/components/template/controls/ScanRangeInput.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useReducer } from "react"; import { Box, InputAdornment, @@ -35,74 +35,71 @@ const ScanRangeInput = ({ visible = true, id, }: ScanRangeInputProps) => { - const [scanRange, setScanRange] = useState({ - start: String(value.start), - end: String(value.end), - }); - - const [excludedRaw, setExcludedRaw] = useState( - (value.excluded ?? []).join(", "), - ); - const [componentError, setComponentError] = useState({ - start: "", - end: "", - excluded: "", - }); - - const [hasUserEditedExcluded, setHasUserEditedExcluded] = useState(false); + interface ScanRangeState { + componentError: { + start: string; + end: string; + excluded: string; + }; + excludedRaw: string; + start: string; + end: string; + } + + interface ScanRangeAction { + type: "start" | "end" | "excluded"; + payload: string; + } + + function scanRangeReducer( + state: ScanRangeState, + action: ScanRangeAction, + ): ScanRangeState { + const newState = { ...state }; + + switch (action.type) { + case "start": + newState.start = action.payload; + break; + case "end": + newState.end = action.payload; + break; + case "excluded": + newState.excludedRaw = action.payload; + break; + default: + return state; + } - const validateAndUpdate = useCallback(() => { const result = validateScanRange( - scanRange.start, - scanRange.end, - excludedRaw, + newState.start, + newState.end, + newState.excludedRaw, ); - setComponentError(result.errors); + newState.componentError = result.errors; if (result.parsed) { - const current: ScanRange = { - start: Number(scanRange.start), - end: Number(scanRange.end), + const scanRangeValue = { + start: result.parsed.start, + end: result.parsed.end, excluded: result.parsed.excluded, }; - - const isEqual = - current.start === value.start && - current.end === value.end && - JSON.stringify(current.excluded) === - JSON.stringify(value.excluded ?? []); - - if (!isEqual) { - handleChange(name, result.parsed); - } + handleChange(name, scanRangeValue); } - }, [scanRange, excludedRaw, handleChange, name, value]); - const handleFieldChange = - (field: keyof typeof scanRange) => - (event: React.ChangeEvent) => { - setScanRange({ ...scanRange, [field]: event.target.value }); - }; - - const handleExcludedChange = (event: React.ChangeEvent) => { - setExcludedRaw(event.target.value); - setHasUserEditedExcluded(true); - }; - - useEffect(() => { - setScanRange({ - start: String(value.start), - end: String(value.end), - }); - - if (!hasUserEditedExcluded) { - setExcludedRaw((value.excluded ?? []).join(", ")); - } - }, [value.start, value.end, value.excluded, hasUserEditedExcluded]); - - useEffect(() => { - validateAndUpdate(); - }, [scanRange, excludedRaw, validateAndUpdate]); + return newState; + } + + const [scanRange, handleScanRange] = useReducer(scanRangeReducer, { + componentError: { + start: "", + end: "", + excluded: "", + }, + excludedRaw: Array.isArray(value.excluded) ? value.excluded.join(", ") : "", + start: String(value.start || ""), + end: String(value.end || ""), + }); if (!visible) return null; @@ -141,9 +138,11 @@ const ScanRangeInput = ({ type="number" label="Start" value={scanRange.start} - onChange={handleFieldChange("start")} - error={!!componentError.start} - helperText={componentError.start || " "} + onChange={(event) => { + handleScanRange({ type: "start", payload: event.target.value }); + }} + error={!!scanRange.componentError.start} + helperText={scanRange.componentError.start || " "} disabled={!enabled} slotProps={{ htmlInput: { step: 1 }, @@ -161,9 +160,11 @@ const ScanRangeInput = ({ type="number" label="End" value={scanRange.end} - onChange={handleFieldChange("end")} - error={!!componentError.end} - helperText={componentError.end || " "} + onChange={(event) => { + handleScanRange({ type: "end", payload: event.target.value }); + }} + error={!!scanRange.componentError.end} + helperText={scanRange.componentError.end || " "} disabled={!enabled} slotProps={{ htmlInput: { step: 1 }, @@ -179,10 +180,12 @@ const ScanRangeInput = ({ { + handleScanRange({ type: "excluded", payload: event.target.value }); + }} + error={!!scanRange.componentError.excluded} + helperText={scanRange.componentError.excluded || " "} disabled={!enabled} slotProps={{ formHelperText: { diff --git a/frontend/workflows-lib/lib/components/template/jsonforms/JsonFormsScanRangeRenderer.tsx b/frontend/workflows-lib/lib/components/template/jsonforms/JsonFormsScanRangeRenderer.tsx index 75764e68b..e745cdc83 100644 --- a/frontend/workflows-lib/lib/components/template/jsonforms/JsonFormsScanRangeRenderer.tsx +++ b/frontend/workflows-lib/lib/components/template/jsonforms/JsonFormsScanRangeRenderer.tsx @@ -5,7 +5,7 @@ import { withJsonFormsControlProps } from "@jsonforms/react"; import { ControlProps } from "@jsonforms/core"; import ScanRangeInput from "../controls/ScanRangeInput"; import { ScanRange } from "../../../types"; -import { useEffect, useState } from "react"; +import { useMemo } from "react"; const ScanRangeControl = ({ data, @@ -19,23 +19,16 @@ const ScanRangeControl = ({ visible, id, }: ControlProps) => { - const [localValue, setLocalValue] = useState({ - start: data?.start, - end: data?.end, - excluded: data?.excluded ?? [], - }); - const handleBufferedChange = (_: string, value: ScanRange) => { - setLocalValue(value); handleChange(path, value); }; - useEffect(() => { - setLocalValue({ + const localValue: ScanRange = useMemo(() => { + return { start: data?.start, end: data?.end, excluded: data?.excluded ?? [], - }); + }; }, [data]); return ( diff --git a/frontend/workflows-lib/lib/components/workflow/ScrollableImages.tsx b/frontend/workflows-lib/lib/components/workflow/ScrollableImages.tsx index 5e026c5e3..a4b06d198 100644 --- a/frontend/workflows-lib/lib/components/workflow/ScrollableImages.tsx +++ b/frontend/workflows-lib/lib/components/workflow/ScrollableImages.tsx @@ -120,7 +120,7 @@ const ScrollableImages = ({ }; }, [handlePrev, handleNext]); - useEffect(() => { + useCallback(() => { if (currentIndex >= imageListLength && imageListLength) { setCurrentIndexWrapper(imageListLength - 1); } diff --git a/frontend/workflows-lib/lib/components/workflow/TaskInfo.tsx b/frontend/workflows-lib/lib/components/workflow/TaskInfo.tsx index 2517b3485..e37e474ac 100644 --- a/frontend/workflows-lib/lib/components/workflow/TaskInfo.tsx +++ b/frontend/workflows-lib/lib/components/workflow/TaskInfo.tsx @@ -9,7 +9,7 @@ import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown"; import { ArtifactFilteredList } from "./ArtifactFilteredList"; import type { Artifact } from "workflows-lib"; import { ImageInfo, ScrollableImages } from "./ScrollableImages"; -import { useState, useEffect, useMemo } from "react"; +import { useState, useMemo } from "react"; import { FuzzySearchBar } from "./FuzzySearchBar"; import { FileTypeDropdown } from "./FileTypeDropdown"; import Fuse from "fuse.js"; @@ -25,9 +25,6 @@ export const TaskInfo: React.FC = ({ }) => { const [searchQuery, setSearchQuery] = useState(""); const [selectedFileTypes, setSelectedFileTypes] = useState([]); - const [filteredArtifactList, setFilteredArtifactList] = useState( - [], - ); const fileTypes = useMemo(() => { const types = artifactList @@ -40,7 +37,7 @@ export const TaskInfo: React.FC = ({ return types; }, [artifactList]); - useEffect(() => { + const filteredArtifactList: Artifact[] = useMemo(() => { let filtered = artifactList; if (selectedFileTypes.length > 0) { @@ -63,7 +60,7 @@ export const TaskInfo: React.FC = ({ filtered = results.map((result) => result.item); } - setFilteredArtifactList(filtered); + return filtered; }, [searchQuery, selectedFileTypes, artifactList]); const imageArtifactsInfos: ImageInfo[] = useMemo(() => { From 7e19b8e87c40a641f9e1d20ea75596ea2bcd311b Mon Sep 17 00:00:00 2001 From: Thomas Binu Thomas Date: Mon, 1 Dec 2025 09:19:10 +0000 Subject: [PATCH 2/2] refactor(frontend): modified tests and stories from refactor --- .../stories/components/WorkflowsContent.stories.tsx | 1 - .../tests/components/BaseWorkflowRelay.test.tsx | 4 ++-- .../relay-workflows-lib/tests/components/TasksFlow.test.tsx | 4 ++-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/frontend/relay-workflows-lib/stories/components/WorkflowsContent.stories.tsx b/frontend/relay-workflows-lib/stories/components/WorkflowsContent.stories.tsx index e8ecb6af3..565a99a16 100644 --- a/frontend/relay-workflows-lib/stories/components/WorkflowsContent.stories.tsx +++ b/frontend/relay-workflows-lib/stories/components/WorkflowsContent.stories.tsx @@ -68,6 +68,5 @@ export const Default: Story = { onPageChange: () => {}, onLimitChange: () => {}, updatePageInfo: () => {}, - setIsPaginated: () => {}, }, }; diff --git a/frontend/relay-workflows-lib/tests/components/BaseWorkflowRelay.test.tsx b/frontend/relay-workflows-lib/tests/components/BaseWorkflowRelay.test.tsx index 890b0f7e0..d47331f20 100644 --- a/frontend/relay-workflows-lib/tests/components/BaseWorkflowRelay.test.tsx +++ b/frontend/relay-workflows-lib/tests/components/BaseWorkflowRelay.test.tsx @@ -78,8 +78,8 @@ describe("BaseWorkflowRelay", () => { expect(screen.queryByText("even")).not.toBeVisible(); await user.click(accordionButton); - expect(accordionButton).toHaveAttribute("aria-expanded", "true"); - expect(screen.getByText("even")).toBeVisible(); + expect(screen.getByText("React Flow")).toBeVisible(); + expect(screen.getByText("even")).toBeInTheDocument(); }); }); diff --git a/frontend/relay-workflows-lib/tests/components/TasksFlow.test.tsx b/frontend/relay-workflows-lib/tests/components/TasksFlow.test.tsx index 4e440db94..f9bff5169 100644 --- a/frontend/relay-workflows-lib/tests/components/TasksFlow.test.tsx +++ b/frontend/relay-workflows-lib/tests/components/TasksFlow.test.tsx @@ -112,7 +112,7 @@ describe("TasksFlow Component", () => { nodes: mockLayoutedNodes, edges: mockLayoutedEdges, // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - nodeTypes: expect.objectContaining({ custom: expect.any(Function) }), + nodeTypes: { custom: expect.any(Function) }, nodesDraggable: false, nodesConnectable: false, elementsSelectable: true, @@ -121,7 +121,7 @@ describe("TasksFlow Component", () => { zoomOnDoubleClick: false, panOnDrag: true, preventScrolling: false, - fitView: false, + fitView: true, style: { width: "100%", overflow: "auto", height: "100%" }, }), {},