Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: update all outdated components at once #4763

Merged
merged 13 commits into from
Nov 25, 2024
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions src/frontend/src/CustomNodes/hooks/use-update-all-nodes.tsx
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 4 additions & 0 deletions src/frontend/src/CustomNodes/hooks/use-update-node-code.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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) => {
Expand All @@ -32,6 +35,7 @@ const useUpdateNodeCode = (
return newNode;
});

setComponentsToUpdate((old) => old.filter((id) => id !== dataId));
updateNodeInternals(dataId);
},
[dataId, dataNode, setNode, setIsOutdated, updateNodeInternals],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,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";

Expand Down Expand Up @@ -514,6 +515,8 @@ export default function Page({ view }: { view?: boolean }): JSX.Element {
};
}, [isAddingNote, shadowBoxWidth, shadowBoxHeight]);

const componentsToUpdate = useFlowStore((state) => state.componentsToUpdate);

return (
<div className="h-full w-full bg-canvas" ref={reactFlowWrapper}>
{showCanvas ? (
Expand Down Expand Up @@ -593,6 +596,7 @@ export default function Page({ view }: { view?: boolean }): JSX.Element {
<span className="text-foreground">Components</span>
</SidebarTrigger>
</Panel>
{componentsToUpdate.length > 0 && <UpdateAllComponents />}
<SelectionMenu
lastSelection={lastSelection}
isVisible={selectionMenuVisible}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { ForwardedIconComponent } from "@/components/genericIconComponent";
import { Button } from "@/components/ui/button";
import { usePostValidateComponentCode } from "@/controllers/API/queries/nodes/use-post-validate-component-code";
import { processNodeAdvancedFields } from "@/CustomNodes/helpers/process-node-advanced-fields";
import useUpdateAllNodes, {
UpdateNodesType,
} from "@/CustomNodes/hooks/use-update-all-nodes";
import useAlertStore from "@/stores/alertStore";
import useFlowsManagerStore from "@/stores/flowsManagerStore";
import useFlowStore from "@/stores/flowStore";
import { useTypesStore } from "@/stores/typesStore";
import { cn } from "@/utils/utils";
import { useState } from "react";
import { useUpdateNodeInternals } from "reactflow";

export default function UpdateAllComponents() {
const { componentsToUpdate, nodes, edges, setNodes } = useFlowStore();
const updateNodeInternals = useUpdateNodeInternals();
const templates = useTypesStore((state) => 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 (
<div
className={cn(
"text-warning-foreground bg-warning absolute bottom-2 left-1/2 z-50 flex w-[500px] -translate-x-1/2 items-center gap-8 rounded-lg px-4 py-2 text-sm font-medium shadow-md transition-all ease-in",
dismissed && "translate-y-[120%]",
)}
>
<div className="flex items-center gap-3">
<ForwardedIconComponent
name="AlertTriangle"
className="!h-[18px] !w-[18px] shrink-0"
strokeWidth={1.5}
/>
<span>
{componentsToUpdate.length} component
{componentsToUpdate.length > 1 ? "s" : ""} are ready to update
</span>
</div>
<div className="flex items-center gap-4">
<Button
variant="link"
size="icon"
className="text-warning-foreground shrink-0 text-sm"
onClick={() => {
setDismissed(true);
}}
>
Dismiss
</Button>
<Button
variant="warning"
size="sm"
className="shrink-0"
onClick={handleUpdateAllComponents}
loading={loadingUpdate}
>
Update All
</Button>
</div>
</div>
);
}
19 changes: 13 additions & 6 deletions src/frontend/src/stores/flowStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,20 +58,27 @@ const useFlowStore = create<FlowStoreType>((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 });
},
Expand Down Expand Up @@ -700,7 +707,7 @@ const useFlowStore = create<FlowStoreType>((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.",
Expand Down
5 changes: 4 additions & 1 deletion src/frontend/src/types/zustand/flow/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
65 changes: 65 additions & 0 deletions src/frontend/tests/extended/features/outdated-actions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
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(
"src/frontend/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);
});
Loading