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);
+});