diff --git a/src/frontend/src/CustomNodes/hooks/use-update-all-nodes.tsx b/src/frontend/src/CustomNodes/hooks/use-update-all-nodes.tsx new file mode 100644 index 000000000..b507cb4d0 --- /dev/null +++ b/src/frontend/src/CustomNodes/hooks/use-update-all-nodes.tsx @@ -0,0 +1,57 @@ +import { cloneDeep } from "lodash"; +import { useCallback } from "react"; +import { APIClassType } from "../../types/api"; +import { NodeType } from "../../types/flow"; + +export type UpdateNodesType = { + nodeId: string; + newNode: APIClassType; + code: string; + name: string; + type?: string; +}; + +const useUpdateAllNodes = ( + setNodes: (callback: (oldNodes: NodeType[]) => NodeType[]) => void, + updateNodeInternals: (nodeId: string) => void, +) => { + const updateAllNodes = useCallback( + (updates: UpdateNodesType[]) => { + setNodes((oldNodes) => { + const newNodes = cloneDeep(oldNodes); + + updates.forEach(({ nodeId, newNode, code, name, type }) => { + const nodeIndex = newNodes.findIndex((n) => n.id === nodeId); + if (nodeIndex === -1) return; + + const updatedNode = newNodes[nodeIndex]; + updatedNode.data = { + ...updatedNode.data, + node: { + ...newNode, + description: + newNode.description ?? updatedNode.data.node?.description, + display_name: + newNode.display_name ?? updatedNode.data.node?.display_name, + edited: false, + }, + }; + + if (type) { + updatedNode.data.type = type; + } + + updatedNode.data.node!.template[name].value = code; + updateNodeInternals(nodeId); + }); + + return newNodes; + }); + }, + [setNodes, updateNodeInternals], + ); + + return updateAllNodes; +}; + +export default useUpdateAllNodes; diff --git a/src/frontend/src/CustomNodes/hooks/use-update-node-code.tsx b/src/frontend/src/CustomNodes/hooks/use-update-node-code.tsx index beb0430c8..961a8056b 100644 --- a/src/frontend/src/CustomNodes/hooks/use-update-node-code.tsx +++ b/src/frontend/src/CustomNodes/hooks/use-update-node-code.tsx @@ -1,3 +1,4 @@ +import useFlowStore from "@/stores/flowStore"; import { cloneDeep } from "lodash"; // or any other deep cloning library you prefer import { useCallback } from "react"; import { APIClassType } from "../../types/api"; @@ -10,6 +11,8 @@ const useUpdateNodeCode = ( setIsUserEdited: (value: boolean) => void, updateNodeInternals: (id: string) => void, ) => { + const { setComponentsToUpdate } = useFlowStore(); + const updateNodeCode = useCallback( (newNodeClass: APIClassType, code: string, name: string, type: string) => { setNode(dataId, (oldNode) => { @@ -32,6 +35,7 @@ const useUpdateNodeCode = ( return newNode; }); + setComponentsToUpdate((old) => old.filter((id) => id !== dataId)); updateNodeInternals(dataId); }, [dataId, dataNode, setNode, setIsOutdated, updateNodeInternals], diff --git a/src/frontend/src/pages/FlowPage/components/PageComponent/index.tsx b/src/frontend/src/pages/FlowPage/components/PageComponent/index.tsx index 2c871aba9..566a96382 100644 --- a/src/frontend/src/pages/FlowPage/components/PageComponent/index.tsx +++ b/src/frontend/src/pages/FlowPage/components/PageComponent/index.tsx @@ -64,6 +64,7 @@ import { } from "../../../../utils/reactflowUtils"; import ConnectionLineComponent from "../ConnectionLineComponent"; import SelectionMenu from "../SelectionMenuComponent"; +import UpdateAllComponents from "../UpdateAllComponents"; import getRandomName from "./utils/get-random-name"; import isWrappedWithClass from "./utils/is-wrapped-with-class"; @@ -513,6 +514,8 @@ export default function Page({ view }: { view?: boolean }): JSX.Element { }; }, [isAddingNote, shadowBoxWidth, shadowBoxHeight]); + const componentsToUpdate = useFlowStore((state) => state.componentsToUpdate); + return (
{showCanvas ? ( @@ -591,6 +594,7 @@ export default function Page({ view }: { view?: boolean }): JSX.Element { Components + {componentsToUpdate.length > 0 && } state.templates); + const setErrorData = useAlertStore((state) => state.setErrorData); + const [loadingUpdate, setLoadingUpdate] = useState(false); + + const { mutateAsync: validateComponentCode } = usePostValidateComponentCode(); + const takeSnapshot = useFlowsManagerStore((state) => state.takeSnapshot); + + const updateAllNodes = useUpdateAllNodes(setNodes, updateNodeInternals); + + const [dismissed, setDismissed] = useState(false); + + const handleUpdateAllComponents = () => { + setLoadingUpdate(true); + takeSnapshot(); + + let updatedCount = 0; + const updates: UpdateNodesType[] = []; + + const updatePromises = componentsToUpdate.map((nodeId) => { + const node = nodes.find((n) => n.id === nodeId); + if (!node) return Promise.resolve(); + + const thisNodeTemplate = templates[node.data.type]?.template; + if (!thisNodeTemplate?.code) return Promise.resolve(); + + const currentCode = thisNodeTemplate.code.value; + + return new Promise((resolve) => { + validateComponentCode({ + code: currentCode, + frontend_node: node.data.node, + }) + .then(({ data: resData, type }) => { + if (resData && type) { + const newNode = processNodeAdvancedFields(resData, edges, nodeId); + + updates.push({ + nodeId, + newNode, + code: currentCode, + name: "code", + type, + }); + + updatedCount++; + } + resolve(null); + }) + .catch((error) => { + console.error(error); + resolve(null); + }); + }); + }); + + Promise.all(updatePromises) + .then(() => { + if (updatedCount > 0) { + // Batch update all nodes at once + updateAllNodes(updates); + + useAlertStore.getState().setSuccessData({ + title: `Successfully updated ${updatedCount} component${ + updatedCount > 1 ? "s" : "" + }`, + }); + } + }) + .catch((error) => { + setErrorData({ + title: "Error updating components", + list: [ + "There was an error updating the components.", + "If the error persists, please report it on our Discord or GitHub.", + ], + }); + console.error(error); + }) + .finally(() => { + setLoadingUpdate(false); + }); + }; + + if (componentsToUpdate.length === 0) return null; + + return ( +
+
+ + + {componentsToUpdate.length} component + {componentsToUpdate.length > 1 ? "s" : ""} are ready to update + +
+
+ + +
+
+ ); +} diff --git a/src/frontend/src/stores/flowStore.ts b/src/frontend/src/stores/flowStore.ts index 9669753fc..b1489527c 100644 --- a/src/frontend/src/stores/flowStore.ts +++ b/src/frontend/src/stores/flowStore.ts @@ -57,20 +57,27 @@ const useFlowStore = create((set, get) => ({ } }, autoSaveFlow: undefined, - componentsToUpdate: false, + componentsToUpdate: [], + setComponentsToUpdate: (change) => { + let newChange = + typeof change === "function" ? change(get().componentsToUpdate) : change; + set({ componentsToUpdate: newChange }); + }, updateComponentsToUpdate: (nodes) => { - let outdatedNodes = false; + let outdatedNodes: string[] = []; const templates = useTypesStore.getState().templates; for (let i = 0; i < nodes.length; i++) { const currentCode = templates[nodes[i].data?.type]?.template?.code?.value; const thisNodesCode = nodes[i].data?.node!.template?.code?.value; - outdatedNodes = + if ( currentCode && thisNodesCode && currentCode !== thisNodesCode && !nodes[i].data?.node?.edited && - !componentsToIgnoreUpdate.includes(nodes[i].data?.type); - if (outdatedNodes) break; + !componentsToIgnoreUpdate.includes(nodes[i].data?.type) + ) { + outdatedNodes.push(nodes[i].id); + } } set({ componentsToUpdate: outdatedNodes }); }, @@ -702,7 +709,7 @@ const useFlowStore = create((set, get) => ({ ?.map((element) => element.id) .filter(Boolean) as string[]) ?? get().nodes.map((n) => n.id); useFlowStore.getState().updateBuildStatus(idList, BuildStatus.ERROR); - if (get().componentsToUpdate) + if (get().componentsToUpdate.length > 0) setErrorData({ title: "There are outdated components in the flow. The error could be related to them.", diff --git a/src/frontend/src/types/zustand/flow/index.ts b/src/frontend/src/types/zustand/flow/index.ts index b8aa9415e..b53653cc5 100644 --- a/src/frontend/src/types/zustand/flow/index.ts +++ b/src/frontend/src/types/zustand/flow/index.ts @@ -56,7 +56,10 @@ export type FlowPoolType = { export type FlowStoreType = { fitViewNode: (nodeId: string) => void; autoSaveFlow: (() => void) | undefined; - componentsToUpdate: boolean; + componentsToUpdate: string[]; + setComponentsToUpdate: ( + update: string[] | ((oldState: string[]) => string[]), + ) => void; updateComponentsToUpdate: (nodes: Node[]) => void; onFlowPage: boolean; setOnFlowPage: (onFlowPage: boolean) => void; diff --git a/src/frontend/tests/extended/features/outdated-actions.spec.ts b/src/frontend/tests/extended/features/outdated-actions.spec.ts new file mode 100644 index 000000000..1f98fd157 --- /dev/null +++ b/src/frontend/tests/extended/features/outdated-actions.spec.ts @@ -0,0 +1,62 @@ +import { expect, test } from "@playwright/test"; +import { readFileSync } from "fs"; + +test("user must be able to update outdated components", async ({ page }) => { + await page.goto("/"); + + let modalCount = 0; + try { + const modalTitleElement = await page?.getByTestId("modal-title"); + if (modalTitleElement) { + modalCount = await modalTitleElement.count(); + } + } catch (error) { + modalCount = 0; + } + + while (modalCount === 0) { + await page.getByText("New Flow", { exact: true }).click(); + await page.waitForTimeout(3000); + modalCount = await page.getByTestId("modal-title")?.count(); + } + await page.locator("span").filter({ hasText: "Close" }).first().click(); + + await page.locator("span").filter({ hasText: "My Collection" }).isVisible(); + // Read your file into a buffer. + const jsonContent = readFileSync("tests/assets/outdated_flow.json", "utf-8"); + + // Create the DataTransfer and File + const dataTransfer = await page.evaluateHandle((data) => { + const dt = new DataTransfer(); + // Convert the buffer to a hex array + const file = new File([data], "outdated_flow.json", { + type: "application/json", + }); + dt.items.add(file); + return dt; + }, jsonContent); + + // Now dispatch + await page.getByTestId("cards-wrapper").dispatchEvent("drop", { + dataTransfer, + }); + + await page.waitForTimeout(3000); + + await page.getByTestId("list-card").first().click(); + + await page.waitForSelector("text=components are ready to update", { + timeout: 30000, + state: "visible", + }); + + let outdatedComponents = await page.getByTestId("icon-AlertTriangle").count(); + expect(outdatedComponents).toBeGreaterThan(0); + + await page.getByText("Update All", { exact: true }).click(); + + await page.waitForTimeout(3000); + + outdatedComponents = await page.getByTestId("icon-AlertTriangle").count(); + expect(outdatedComponents).toBe(0); +});