feat: add breaking change update modal, refactor dismissed updates (#7882)

* fix: add optional method property to OutputFieldType

* feat: Enhance GenericNode with breaking change detection

- Added state management for breaking changes in GenericNode.
- Updated useCheckCodeValidity hook to evaluate breaking changes based on outputs and template keys.
- Improved node status color logic to reflect breaking changes and outdated code.
- Enhanced UI feedback for users with appropriate alerts and dismiss options.

* refactor: Improve breaking change handling in useCheckCodeValidity hook

- Simplified logic for detecting breaking changes and outdated code.
- Updated state management to ensure accurate status updates based on user inputs and templates.
- Enhanced readability by consolidating related checks into a single conditional structure.

* Fix outdated check

* Componentized breaking change

* Updated design of update handle on node

* Added small-update to modal sizes

* updated duplicate flow hook to duplicate just a flow

* Added update component modal with updating for single component

* Added new duplicateFlow on dropdown on main page

* use new update code modal on generic node

* delete check code validity

* add new check code vaildity util function

* removed unused sets from update node code

* Make componentsToUpdate contain breaking info

* Make Generic Node use Components to Update

* Change border in Node Status

* Stop propagation on node update

* Update update all components to have changes from figma

* updated flow store type and added components to update

* Update update component modal

* added icon on outdatedNodes

* Added id filtering on update components

* Added table with components to update

* Update styling

* Update update component modal to use table component

* Updated styles

* filter map

* Update select to not allow selecting texts on backup flow

* Update cursor for label

* Update text of backup flow

* Try to update selection

* Fix selection of components on opening modal

* Insert Update button on node toolbar if dismissed

* Added new parameters of node toolbar

* Added new types of node toolbar

* Removed update button from node status

* Updated shadcn theme

* Added dismiss by node, added dismissing to local storage, added correct update display

* Clarified update warnings in the UpdateComponentModal to better inform users about potential breaking changes and the need to reconnect components.

* Refactored update component visibility logic in GenericNode to use a memoized value for improved performance and readability.

* Updated test for outdated components to reflect changes in button selectors and improved visibility assertions for update notifications.

* Simplified visibility assertion in outdated components test to check for a more concise update message.

* Fixed edges not coming back after undoing

* Fixed breaking change check to not be checked if code is the same

* Fixed imports

* removed unused functions

* updated icon color

* updated test id

* updated for function to foreach

* updated data testid

* updated outdated flow

* removed flowToCanvas that caused bug when going from main page to flow page

* [autofix.ci] apply automated fixes

* Fixed outdated actions test

* fixed timeouts

* Added check for Backup

---------

Co-authored-by: Gabriel Luiz Freitas Almeida <gabriel@langflow.org>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Lucas Oliveira 2025-05-09 09:30:32 -03:00 committed by GitHub
commit 79e35834b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1160 additions and 419 deletions

View file

@ -39,10 +39,11 @@ export default function NodeStatus({
showNode,
data,
buildStatus,
dismissAll,
isOutdated,
isUserEdited,
isBreakingChange,
getValidationStatus,
handleUpdateComponent,
}: {
nodeId: string;
display_name: string;
@ -52,10 +53,11 @@ export default function NodeStatus({
showNode: boolean;
data: NodeDataType;
buildStatus: BuildStatus;
dismissAll: boolean;
isOutdated: boolean;
isUserEdited: boolean;
isBreakingChange: boolean;
getValidationStatus: (data) => VertexBuildTypeAPI | null;
handleUpdateComponent: () => void;
}) {
const nodeId_ = data.node?.flow?.data
? (findLastNode(data.node?.flow.data!)?.id ?? nodeId)
@ -182,8 +184,6 @@ export default function NodeStatus({
getValidationStatus,
);
const dismissAll = useUtilityStore((state) => state.dismissAll);
const getBaseBorderClass = (selected) => {
let className =
selected && !isBuilding
@ -191,8 +191,8 @@ export default function NodeStatus({
: "border ring-[0.5px] hover:shadow-node ring-border";
let frozenClass = selected ? "border-ring-frozen" : "border-frozen";
let updateClass =
isOutdated && !isUserEdited && !dismissAll
? "border-warning ring-2 ring-warning"
isOutdated && !isUserEdited && !dismissAll && isBreakingChange
? "border-warning"
: "";
return cn(frozen ? frozenClass : className, updateClass);
};
@ -464,36 +464,6 @@ export default function NodeStatus({
)}
</div>
</ShadTooltip>
{dismissAll && isOutdated && !isUserEdited && (
<ShadTooltip content="Update component">
<div
className="button-run-bg hit-area-icon ml-1 bg-warning hover:bg-warning/80"
onClick={(e) => {
e.stopPropagation();
handleUpdateComponent();
e.stopPropagation();
}}
>
{showNode && (
<Button
unstyled
type="button"
onClick={(e) => e.preventDefault()}
>
<div
data-testid={`button_update_` + display_name.toLowerCase()}
>
<IconComponent
name={"AlertTriangle"}
strokeWidth={ICON_STROKE_WIDTH}
className="icon-size text-black"
/>
</div>
</Button>
)}
</div>
</ShadTooltip>
)}
</div>
</>
) : (

View file

@ -0,0 +1,62 @@
import ForwardedIconComponent from "@/components/common/genericIconComponent";
import { Button } from "@/components/ui/button";
import { ICON_STROKE_WIDTH } from "@/constants/constants";
import { cn } from "@/utils/utils";
export default function NodeUpdateComponent({
hasBreakingChange,
showNode,
handleUpdateCode,
loadingUpdate,
setDismissAll,
}: {
hasBreakingChange: boolean;
showNode: boolean;
handleUpdateCode: () => void;
loadingUpdate: boolean;
setDismissAll: (value: boolean) => void;
}) {
return (
<div
className={cn(
"flex w-full items-center gap-3 rounded-t-[0.69rem] border-b bg-muted p-2 px-4 py-2",
)}
>
<div
className={cn(
"h-2.5 w-2.5 rounded-full",
hasBreakingChange ? "bg-warning" : "bg-status-green",
)}
/>
<div className="mb-px flex-1 truncate text-mmd font-medium">
{showNode && (hasBreakingChange ? "Update available" : "Update ready")}
</div>
<Button
variant="ghost"
size="icon"
className="shrink-0 !text-mmd"
onClick={(e) => {
e.stopPropagation();
setDismissAll(true);
}}
aria-label="Dismiss warning bar"
data-testid="dismiss-warning-bar"
>
Dismiss
</Button>
<Button
size="sm"
className="!h-8 shrink-0 !text-mmd"
onClick={(e) => {
e.stopPropagation();
handleUpdateCode();
}}
loading={loadingUpdate}
data-testid={hasBreakingChange ? "review-button" : "update-button"}
>
{hasBreakingChange ? "Review" : "Update"}
</Button>
</div>
);
}

View file

@ -1,11 +1,13 @@
import ForwardedIconComponent from "@/components/common/genericIconComponent";
import ShadTooltip from "@/components/common/shadTooltipComponent";
import { usePostValidateComponentCode } from "@/controllers/API/queries/nodes/use-post-validate-component-code";
import UpdateComponentModal from "@/modals/updateComponentModal";
import { useAlternate } from "@/shared/hooks/use-alternate";
import { useUtilityStore } from "@/stores/utilityStore";
import { FlowStoreType } from "@/types/zustand/flow";
import { useUpdateNodeInternals } from "@xyflow/react";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import { useShallow } from "zustand/react/shallow";
import { Button } from "../../components/ui/button";
import {
ICON_STROKE_WIDTH,
@ -24,12 +26,12 @@ import { NodeDataType } from "../../types/flow";
import { checkHasToolMode } from "../../utils/reactflowUtils";
import { classNames, cn } from "../../utils/utils";
import { processNodeAdvancedFields } from "../helpers/process-node-advanced-fields";
import useCheckCodeValidity from "../hooks/use-check-code-validity";
import useUpdateNodeCode from "../hooks/use-update-node-code";
import NodeDescription from "./components/NodeDescription";
import NodeName from "./components/NodeName";
import { OutputParameter } from "./components/NodeOutputParameter";
import NodeStatus from "./components/NodeStatus";
import NodeUpdateComponent from "./components/NodeUpdateComponent";
import RenderInputParameters from "./components/RenderInputParameters";
import { NodeIcon } from "./components/nodeIcon";
import { useBuildStatus } from "./hooks/use-get-build-status";
@ -72,13 +74,12 @@ function GenericNode({
xPos?: number;
yPos?: number;
}): JSX.Element {
const [isOutdated, setIsOutdated] = useState(false);
const [isUserEdited, setIsUserEdited] = useState(false);
const [borderColor, setBorderColor] = useState<string>("");
const [loadingUpdate, setLoadingUpdate] = useState(false);
const [showHiddenOutputs, setShowHiddenOutputs] = useState(false);
const [validationStatus, setValidationStatus] =
useState<VertexBuildTypeAPI | null>(null);
const [openUpdateModal, setOpenUpdateModal] = useState(false);
const types = useTypesStore((state) => state.types);
const templates = useTypesStore((state) => state.templates);
@ -90,7 +91,15 @@ function GenericNode({
const edges = useFlowStore((state) => state.edges);
const shortcuts = useShortcutsStore((state) => state.shortcuts);
const buildStatus = useBuildStatus(data, data.id);
const dismissAll = useUtilityStore((state) => state.dismissAll);
const dismissedNodes = useFlowStore((state) => state.dismissedNodes);
const addDismissedNodes = useFlowStore((state) => state.addDismissedNodes);
const removeDismissedNodes = useFlowStore(
(state) => state.removeDismissedNodes,
);
const dismissAll = useMemo(
() => dismissedNodes.includes(data.id),
[dismissedNodes, data.id],
);
const showNode = data.showNode ?? true;
@ -104,16 +113,31 @@ function GenericNode({
const [editNameDescription, toggleEditNameDescription, set] =
useAlternate(false);
const componentUpdate = useFlowStore(
useShallow((state: FlowStoreType) =>
state.componentsToUpdate.find((component) => component.id === data.id),
),
);
const {
outdated: isOutdated,
breakingChange: hasBreakingChange,
userEdited: isUserEdited,
} = componentUpdate ?? {
outdated: false,
breakingChange: false,
userEdited: false,
};
const updateNodeCode = useUpdateNodeCode(
data?.id,
data.node!,
setNode,
setIsOutdated,
setIsUserEdited,
updateNodeInternals,
);
useCheckCodeValidity(data, templates, setIsOutdated, setIsUserEdited, types);
useEffect(() => {
updateNodeInternals(data.id);
}, [data.node.template]);
if (!data.node!.template) {
setErrorData({
@ -127,52 +151,61 @@ function GenericNode({
deleteNode(data.id);
}
const handleUpdateCode = useCallback(() => {
setLoadingUpdate(true);
takeSnapshot();
const handleUpdateCode = useCallback(
(confirmed: boolean = false) => {
if (!confirmed && hasBreakingChange) {
setOpenUpdateModal(true);
return;
}
setLoadingUpdate(true);
takeSnapshot();
const thisNodeTemplate = templates[data.type]?.template;
if (!thisNodeTemplate?.code) return;
const thisNodeTemplate = templates[data.type]?.template;
if (!thisNodeTemplate?.code) return;
const currentCode = thisNodeTemplate.code.value;
if (data.node) {
validateComponentCode(
{ code: currentCode, frontend_node: data.node },
{
onSuccess: ({ data: resData, type }) => {
if (resData && type && updateNodeCode) {
const newNode = processNodeAdvancedFields(
resData,
edges,
data.id,
);
updateNodeCode(newNode, currentCode, "code", type);
const currentCode = thisNodeTemplate.code.value;
if (data.node) {
validateComponentCode(
{ code: currentCode, frontend_node: data.node },
{
onSuccess: ({ data: resData, type }) => {
if (resData && type && updateNodeCode) {
const newNode = processNodeAdvancedFields(
resData,
edges,
data.id,
);
updateNodeCode(newNode, currentCode, "code", type);
removeDismissedNodes([data.id]);
setLoadingUpdate(false);
}
},
onError: (error) => {
setErrorData({
title: "Error updating Component code",
list: [
"There was an error updating the Component.",
"If the error persists, please report it on our Discord or GitHub.",
],
});
console.error(error);
setLoadingUpdate(false);
}
},
},
onError: (error) => {
setErrorData({
title: "Error updating Component code",
list: [
"There was an error updating the Component.",
"If the error persists, please report it on our Discord or GitHub.",
],
});
console.error(error);
setLoadingUpdate(false);
},
},
);
}
}, [
data,
templates,
edges,
updateNodeCode,
validateComponentCode,
setErrorData,
takeSnapshot,
]);
);
}
},
[
data,
templates,
hasBreakingChange,
edges,
updateNodeCode,
validateComponentCode,
setErrorData,
takeSnapshot,
],
);
const handleUpdateCodeWShortcut = useCallback(() => {
if (isOutdated && selected) {
@ -194,11 +227,6 @@ function GenericNode({
[data.node?.outputs, data.node?.tool_mode],
);
const hasToolMode = useMemo(
() => checkHasToolMode(data.node?.template ?? {}),
[data.node?.template],
);
const hasOutputs = useMemo(
() => data.node?.outputs && data.node.outputs.length > 0,
[data.node?.outputs],
@ -270,6 +298,11 @@ function GenericNode({
return useFlowStore.getState().nodes.filter((node) => node.selected).length;
}, [selected]);
const shouldShowUpdateComponent = useMemo(
() => (isOutdated || hasBreakingChange) && !isUserEdited && !dismissAll,
[isOutdated, hasBreakingChange, isUserEdited, dismissAll],
);
const memoizedNodeToolbarComponent = useMemo(() => {
return selected && selectedNodesCount === 1 ? (
<>
@ -295,8 +328,10 @@ function GenericNode({
showNode={showNode}
openAdvancedModal={false}
onCloseAdvancedModal={() => {}}
updateNode={handleUpdateCode}
isOutdated={isOutdated && isUserEdited}
updateNode={() => handleUpdateCode()}
isOutdated={isOutdated && dismissAll}
isUserEdited={isUserEdited}
hasBreakingChange={hasBreakingChange}
/>
</div>
<div className="-z-10">
@ -410,10 +445,11 @@ function GenericNode({
selected={selected}
setBorderColor={setBorderColor}
buildStatus={buildStatus}
dismissAll={dismissAll}
isOutdated={isOutdated}
isUserEdited={isUserEdited}
isBreakingChange={hasBreakingChange}
getValidationStatus={getValidationStatus}
handleUpdateComponent={handleUpdateCode}
/>
);
}, [
@ -463,42 +499,30 @@ function GenericNode({
}, [data, types, isToolMode, showNode, shownOutputs, showHiddenOutputs]);
return (
<div
className={cn(
isOutdated && !isUserEdited && !dismissAll ? "relative -mt-10" : "",
)}
>
<div className={cn(shouldShowUpdateComponent ? "relative -mt-10" : "")}>
<div
className={cn(
borderColor,
showNode ? "w-80" : `w-48`,
"generic-node-div group/node relative rounded-xl shadow-sm hover:shadow-md",
"generic-node-div group/node relative rounded-xl border shadow-sm hover:shadow-md",
!hasOutputs && "pb-4",
)}
>
<UpdateComponentModal
open={openUpdateModal}
setOpen={setOpenUpdateModal}
onUpdateNode={() => handleUpdateCode(true)}
components={componentUpdate ? [componentUpdate] : []}
/>
{memoizedNodeToolbarComponent}
{isOutdated && !isUserEdited && !dismissAll && (
<div className="flex h-10 w-full items-center gap-4 rounded-t-[0.69rem] bg-warning p-2 px-4 text-warning-foreground">
<ForwardedIconComponent
name="AlertTriangle"
strokeWidth={ICON_STROKE_WIDTH}
className="icon-size shrink-0"
/>
<span className="flex-1 truncate text-sm font-medium">
{showNode && "Update Ready"}
</span>
<Button
variant="warning"
size="iconMd"
className="shrink-0 px-2.5 text-xs"
onClick={handleUpdateCode}
loading={loadingUpdate}
data-testid="update-button"
>
Update
</Button>
</div>
{shouldShowUpdateComponent && (
<NodeUpdateComponent
hasBreakingChange={hasBreakingChange}
showNode={showNode}
handleUpdateCode={() => handleUpdateCode()}
loadingUpdate={loadingUpdate}
setDismissAll={() => addDismissedNodes([data.id])}
/>
)}
<div
data-testid={`${data.id}-main-node`}
@ -529,8 +553,7 @@ function GenericNode({
{!showNode && (
<>
{renderInputParameters()}
{shownOutputs &&
shownOutputs.length > 0 &&
{shownOutputs.length > 0 &&
renderOutputs(shownOutputs, "render-outputs")}
</>
)}

View file

@ -0,0 +1,169 @@
import { componentsToIgnoreUpdate } from "@/constants/constants";
import { OutputFieldType } from "@/types/api";
import { NodeDataType } from "../../types/flow";
// Returns true if the code is outdated (code string changed and not ignored)
const codeIsOutdated = (
currentCode: string,
thisNodesCode: string,
type: string,
): boolean => {
return !!(
currentCode &&
thisNodesCode &&
currentCode !== thisNodesCode &&
!componentsToIgnoreUpdate.includes(type)
);
};
// Returns true if there is a breaking change (outputs, template keys, or input_types)
const codeHasBreakingChange = (
originalOutputs?: OutputFieldType[],
userOutputs?: OutputFieldType[],
originalTemplate?: { [key: string]: any },
userTemplate?: { [key: string]: any },
): boolean => {
// Check outputs
if (
originalOutputs &&
userOutputs &&
!outputsAreEqual(originalOutputs, userOutputs)
) {
return true;
}
// Check template keys
if (
originalTemplate &&
userTemplate &&
!templateKeysEqual(originalTemplate, userTemplate)
) {
return true;
}
// Check input_types containment
if (
originalTemplate &&
userTemplate &&
!inputTypesContained(originalTemplate, userTemplate)
) {
return true;
}
return false;
};
export const checkCodeValidity = (
data: NodeDataType,
templates: { [key: string]: any },
) => {
if (!data?.node || !templates) return;
const template = templates[data.type]?.template;
const currentCode = template?.code?.value;
const thisNodesCode = data.node!.template?.code?.value;
const originalOutputs = templates[data.type]?.outputs;
const userOutputs = data.node?.outputs;
const originalTemplate = template;
const userTemplate = data.node?.template;
const isOutdated = codeIsOutdated(currentCode, thisNodesCode, data.type);
const hasBreakingChange = isOutdated
? codeHasBreakingChange(
originalOutputs,
userOutputs,
originalTemplate,
userTemplate,
)
: false;
return {
outdated: isOutdated,
breakingChange: hasBreakingChange,
userEdited: data.node?.edited ?? false,
};
};
// templates[data.type]?.template is the original component while data.node.template is the user's component
// The codeIsOutdated function will have many checks to make sure the code is outdated
// the first check is if the current code is defined
// the second check is if the data.node.outputs are equal to templates[data.type]?.outputs
// and the data.node.template keys are equal to templates[data.type]?.template keys
// and all original input_types in each field are contained in the data.node.template input_types. If so, it means it won't break the component
// this is a breaking change so we will need to handle it
// Deep comparison for outputs (order-independent, returns object with per-output match status)
const outputsComparisonResult = (
originalOutputs: OutputFieldType[] = [],
userOutputs: OutputFieldType[] = [],
): { [outputName: string]: boolean } => {
// Create a map for quick lookup by 'name'
const userOutputMap = new Map<string, OutputFieldType>();
userOutputs.forEach((output) => {
userOutputMap.set(output.name, output);
});
// Build an object with per-output match status
const result: { [outputName: string]: boolean } = {};
originalOutputs.forEach((orig) => {
const user = userOutputMap.get(orig.name);
result[orig.name] =
!!user &&
orig.display_name === user.display_name &&
JSON.stringify(orig.types) === JSON.stringify(user.types) &&
orig.method === user.method &&
orig.allows_loop === user.allows_loop;
});
// Check if all user outputs are present in original outputs
userOutputs.forEach((user) => {
if (!result[user.name]) {
result[user.name] = false;
}
});
return result;
};
const outputsAreEqual = (
originalOutputs: OutputFieldType[],
userOutputs: OutputFieldType[],
): boolean => {
const result = outputsComparisonResult(originalOutputs, userOutputs);
// Object.values is more direct for checking all values
return Object.values(result).every(Boolean);
};
// Helper to check if all input_types in original are contained in user
const inputTypesContained = (
originalTemplate: { [key: string]: any },
userTemplate: { [key: string]: any },
): boolean => {
for (const key of Object.keys(originalTemplate)) {
const origField = originalTemplate[key];
const userField = userTemplate[key];
if (!userField) return false;
if (origField.input_types) {
const origTypes = Array.isArray(origField.input_types)
? origField.input_types
: [];
const userTypes = Array.isArray(userField.input_types)
? userField.input_types
: [];
if (!origTypes.every((t) => userTypes.includes(t))) {
return false;
}
}
}
return true;
};
// Helper to check if template keys are equal
const templateKeysEqual = (
originalTemplate: { [key: string]: any },
userTemplate: { [key: string]: any },
): boolean => {
const origKeys = Object.keys(originalTemplate).sort();
const userKeys = Object.keys(userTemplate).sort();
return JSON.stringify(origKeys) === JSON.stringify(userKeys);
};
export default checkCodeValidity;

View file

@ -1,36 +0,0 @@
import { componentsToIgnoreUpdate } from "@/constants/constants";
import { useEffect } from "react";
import { NodeDataType } from "../../types/flow";
const useCheckCodeValidity = (
data: NodeDataType,
templates: { [key: string]: any },
setIsOutdated: (value: boolean) => void,
setIsUserEdited: (value: boolean) => void,
types,
) => {
useEffect(() => {
// This one should run only once
// first check if data.type in NATIVE_CATEGORIES
// if not return
if (!data?.node || !templates) return;
const currentCode = templates[data.type]?.template?.code?.value;
const thisNodesCode = data.node!.template?.code?.value;
setIsOutdated(
currentCode &&
thisNodesCode &&
currentCode !== thisNodesCode &&
!componentsToIgnoreUpdate.includes(data.type),
);
setIsUserEdited(data.node?.edited ?? false);
// template.code can be undefined
}, [
data.node,
data.node?.template?.code?.value,
templates,
setIsOutdated,
setIsUserEdited,
]);
};
export default useCheckCodeValidity;

View file

@ -8,8 +8,6 @@ const useUpdateNodeCode = (
dataId: string,
dataNode: APIClassType, // Define YourNodeType according to your data structure
setNode: (id: string, callback: (oldNode) => any) => void,
setIsOutdated: (value: boolean) => void,
setIsUserEdited: (value: boolean) => void,
updateNodeInternals: (id: string) => void,
) => {
const { setComponentsToUpdate } = useFlowStore();
@ -30,8 +28,6 @@ const useUpdateNodeCode = (
}
newNode.data.node.template[name].value = code;
setIsOutdated(false);
setIsUserEdited(false);
const outputs = dataNode.outputs;
const updatedOutputs = newNodeClass.outputs;
@ -44,10 +40,12 @@ const useUpdateNodeCode = (
return newNode;
});
setComponentsToUpdate((old) => old.filter((id) => id !== dataId));
setComponentsToUpdate((old) =>
old.filter((component) => component.id !== dataId),
);
updateNodeInternals(dataId);
},
[dataId, dataNode, setNode, setIsOutdated, updateNodeInternals],
[dataId, dataNode, setNode, updateNodeInternals],
);
return updateNodeCode;

View file

@ -10,7 +10,6 @@ import { CustomProductSelector } from "@/customization/components/custom-product
import { ENABLE_DATASTAX_LANGFLOW } from "@/customization/feature-flags";
import { useCustomNavigate } from "@/customization/hooks/use-custom-navigate";
import useTheme from "@/customization/hooks/use-custom-theme";
import { useResetDismissUpdateAll } from "@/hooks/use-reset-dismiss-update-all";
import useAlertStore from "@/stores/alertStore";
import { useEffect, useRef, useState } from "react";
import { AccountMenu } from "./components/AccountMenu";
@ -43,8 +42,6 @@ export default function AppHeader(): JSX.Element {
};
}, []);
useResetDismissUpdateAll();
const getNotificationBadge = () => {
const baseClasses = "absolute h-1 w-1 rounded-full bg-destructive";
return notificationCenter

View file

@ -28,7 +28,6 @@ const useAddFlow = () => {
const setFlows = useFlowsManagerStore((state) => state.setFlows);
const { deleteFlow } = useDeleteFlow();
const { setFlowToCanvas } = useFlowsManagerStore();
const setNoticeData = useAlertStore.getState().setNoticeData;
const { folderId } = useParams();
const myCollectionId = useFolderStore((state) => state.myCollectionId);
@ -93,7 +92,6 @@ const useAddFlow = () => {
}),
}));
setFlowToCanvas(createdFlow);
resolve(createdFlow.id);
},
onError: (error) => {

View file

@ -1,14 +0,0 @@
import { useEffect } from "react";
import { useLocation } from "react-router-dom";
import { useUtilityStore } from "../stores/utilityStore";
export const useResetDismissUpdateAll = () => {
const location = useLocation();
const flowLocationPath = location.pathname.includes("flow");
const setDismissAll = useUtilityStore((state) => state.setDismissAll);
useEffect(() => {
if (flowLocationPath) {
setDismissAll(false);
}
}, [location]);
};

View file

@ -18,6 +18,10 @@ export const switchCaseModalSize = (size: string) => {
minWidth = "min-w-[40vw]";
height = "";
break;
case "small-update":
minWidth = "min-w-[480px] max-w-[480px]";
height = "";
break;
case "small":
minWidth = "min-w-[40vw]";
height = "h-[40vh]";

View file

@ -172,6 +172,7 @@ interface BaseModalProps {
| "retangular"
| "smaller"
| "small"
| "small-update"
| "small-query"
| "medium"
| "medium-tall"

View file

@ -0,0 +1,225 @@
import ForwardedIconComponent from "@/components/common/genericIconComponent";
import TableComponent from "@/components/core/parameterRenderComponent/components/tableComponent";
import { Checkbox } from "@/components/ui/checkbox";
import useDuplicateFlows from "@/pages/MainPage/hooks/use-handle-duplicate";
import useFlowStore from "@/stores/flowStore";
import { ComponentsToUpdateType } from "@/types/zustand/flow";
import { cn } from "@/utils/utils";
import { ColDef } from "ag-grid-community";
import { AgGridReact } from "ag-grid-react";
import { useEffect, useRef, useState } from "react";
import BaseModal from "../baseModal";
export default function UpdateComponentModal({
open,
setOpen,
onUpdateNode,
children,
components,
isMultiple = false,
}: {
open: boolean;
setOpen: (open: boolean) => void;
onUpdateNode: (updatedComponents?: string[]) => void;
children?: React.ReactNode;
components: ComponentsToUpdateType[];
isMultiple?: boolean;
}) {
const [backupFlow, setBackupFlow] = useState<boolean>(true);
const [loading, setLoading] = useState<boolean>(false);
const [selectedComponents, setSelectedComponents] = useState<Set<string>>(
new Set(components.filter((c) => !c.breakingChange).map((c) => c.id)),
);
const agGrid = useRef<AgGridReact>(null);
const currentFlow = useFlowStore((state) => state.currentFlow);
const { handleDuplicate } = useDuplicateFlows({
flow: currentFlow
? { ...currentFlow, name: currentFlow.name + " (Backup)" }
: undefined,
});
const handleUpdate = () => {
setLoading(true);
if (backupFlow) {
handleDuplicate().then(() => {
onUpdateNode(
components.length > 0 ? Array.from(selectedComponents) : undefined,
);
setLoading(false);
setOpen(false);
});
} else {
onUpdateNode(
components.length > 0 ? Array.from(selectedComponents) : undefined,
);
setLoading(false);
setOpen(false);
}
};
const columnDefs: ColDef[] = [
{ field: "id", hide: true },
{
headerName: "Component",
field: "display_name",
headerClass: "!text-mmd !font-normal",
flex: 1,
headerCheckboxSelection: true,
checkboxSelection: true,
resizable: false,
cellRenderer: (params) => {
return (
<div className="flex items-center gap-3">
{params.data.icon && (
<ForwardedIconComponent
name={params.data.icon}
className="h-4 w-4"
/>
)}
{params.value}
</div>
);
},
},
{
headerName: "Update Type",
field: "breakingChange",
headerClass: "!text-mmd !font-normal",
resizable: false,
flex: 1,
cellClass: "text-muted-foreground",
cellRenderer: (params) => {
return params.value ? (
<span className="font-semibold text-accent-amber-foreground">
Breaking
</span>
) : (
<span>Standard</span>
);
},
},
];
useEffect(() => {
if (open) {
setBackupFlow(true);
setSelectedComponents(
new Set(components.filter((c) => !c.breakingChange).map((c) => c.id)),
);
}
}, [open]);
useEffect(() => {
if (agGrid.current) {
agGrid.current?.api?.forEachNode((node) => {
if (selectedComponents.has(node.data.id)) {
node.setSelected(true);
} else {
node.setSelected(false);
}
});
}
}, [agGrid.current, selectedComponents, open]);
return (
<BaseModal
closeButtonClassName="!top-2 !right-3"
open={open}
setOpen={setOpen}
size="small-update"
className="px-4 py-3"
>
<BaseModal.Trigger asChild>{children ?? <></>}</BaseModal.Trigger>
<BaseModal.Header>
<span className="">
Update{" "}
{isMultiple ? "components" : (components?.[0]?.display_name ?? "")}
</span>
</BaseModal.Header>
<BaseModal.Content overflowHidden>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 text-sm text-muted-foreground">
{isMultiple ? (
<p>
Updates marked as{" "}
<span className="font-semibold text-accent-amber-foreground">
breaking
</span>{" "}
may change inputs, outputs, or component behavior. In some
cases, they will disconnect components from your flow, requiring
you to review or reconnect them afterward. Components added from
the sidebar always use the latest version.
</p>
) : (
<>
<p>
This update may change inputs, outputs, or component behavior.
In some cases, it will{" "}
<span className="font-semibold text-accent-amber-foreground">
disconnect this component from your flow
</span>
, requiring you to review or reconnect it afterward.
</p>
<p>
Components added from the sidebar always use the latest
version.
</p>
</>
)}
</div>
{isMultiple && (
<div className="-mx-4">
<TableComponent
columnDefs={columnDefs}
ref={agGrid}
domLayout="autoHeight"
rowData={components}
rowSelection="multiple"
className="ag-tool-mode ag-no-selection"
rowHeight={30}
headerHeight={30}
suppressRowClickSelection={false}
onSelectionChanged={(event) => {
const selectedIds = event.api
.getSelectedRows()
.map((row) => row.id);
setSelectedComponents(new Set(selectedIds));
}}
suppressRowHoverHighlight={true}
tableOptions={{ hide_options: true }}
/>
</div>
)}
<div
className={cn(
"mb-3 flex items-center gap-3 rounded-md border p-3 text-sm transition-all",
!backupFlow && "border-accent-amber-foreground bg-accent-amber",
)}
>
<Checkbox
checked={backupFlow}
onCheckedChange={(checked) =>
setBackupFlow(checked === "indeterminate" ? false : checked)
}
className="bg-muted"
id="backupFlow"
data-testid="backup-flow-checkbox"
/>
<label htmlFor="backupFlow" className="cursor-pointer select-none">
Create backup flow before updating
</label>
</div>
</div>
</BaseModal.Content>
<BaseModal.Footer
submit={{
label: "Update Component" + (components.length > 1 ? "s" : ""),
onClick: handleUpdate,
disabled: isMultiple && selectedComponents.size === 0,
loading,
}}
></BaseModal.Footer>
</BaseModal>
);
}

View file

@ -1,18 +1,17 @@
import ForwardedIconComponent from "@/components/common/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 UpdateComponentModal from "@/modals/updateComponentModal";
import useAlertStore from "@/stores/alertStore";
import useFlowsManagerStore from "@/stores/flowsManagerStore";
import useFlowStore from "@/stores/flowStore";
import { useTypesStore } from "@/stores/typesStore";
import { useUtilityStore } from "@/stores/utilityStore";
import { cn } from "@/utils/utils";
import { useUpdateNodeInternals } from "@xyflow/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMemo, useRef, useState } from "react";
const ERROR_MESSAGE_UPDATING_COMPONENTS = "Error updating components";
const ERROR_MESSAGE_UPDATING_COMPONENTS_LIST = [
@ -24,7 +23,6 @@ const ERROR_MESSAGE_EDGES_LOST =
export default function UpdateAllComponents({}: {}) {
const { componentsToUpdate, nodes, edges, setNodes } = useFlowStore();
const setDismissAll = useUtilityStore((state) => state.setDismissAll);
const templates = useTypesStore((state) => state.templates);
const setErrorData = useAlertStore((state) => state.setErrorData);
const { mutateAsync: validateComponentCode } = usePostValidateComponentCode();
@ -34,7 +32,26 @@ export default function UpdateAllComponents({}: {}) {
const updateAllNodes = useUpdateAllNodes(setNodes, updateNodeInternals);
const [loadingUpdate, setLoadingUpdate] = useState(false);
const [dismissed, setDismissed] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const dismissedNodes = useFlowStore((state) => state.dismissedNodes);
const addDismissedNodes = useFlowStore((state) => state.addDismissedNodes);
const dismissed = useMemo(
() =>
componentsToUpdate.every((component) =>
dismissedNodes.includes(component.id),
),
[dismissedNodes, componentsToUpdate],
);
const componentsToUpdateFiltered = useMemo(
() =>
componentsToUpdate.filter(
(component) => !dismissedNodes.includes(component.id),
),
[componentsToUpdate, dismissedNodes],
);
const edgesUpdateRef = useRef({
numberOfEdgesBeforeUpdate: 0,
@ -62,7 +79,15 @@ export default function UpdateAllComponents({}: {}) {
}`;
};
const handleUpdateAllComponents = () => {
const breakingChanges = componentsToUpdateFiltered.filter(
(component) => component.breakingChange,
);
const handleUpdateAllComponents = (confirmed?: boolean, ids?: string[]) => {
if (!confirmed && breakingChanges.length > 0) {
setIsOpen(true);
return;
}
startEdgesUpdateRef();
setLoadingUpdate(true);
@ -71,42 +96,48 @@ export default function UpdateAllComponents({}: {}) {
let updatedCount = 0;
const updates: UpdateNodesType[] = [];
const updatePromises = componentsToUpdate.map((nodeId) => {
const node = nodes.find((n) => n.id === nodeId);
if (!node || node.type !== "genericNode") return Promise.resolve();
const updatePromises = componentsToUpdateFiltered
.filter((component) => ids?.includes(component.id) ?? true)
.map((nodeUpdate) => {
const node = nodes.find((n) => n.id === nodeUpdate.id);
if (!node || node.type !== "genericNode") return Promise.resolve();
const thisNodeTemplate = templates[node.data.type]?.template;
if (!thisNodeTemplate?.code) return Promise.resolve();
const thisNodeTemplate = templates[node.data.type]?.template;
if (!thisNodeTemplate?.code) return Promise.resolve();
const currentCode = thisNodeTemplate.code.value;
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);
return new Promise((resolve) => {
validateComponentCode({
code: currentCode,
frontend_node: node.data.node!,
})
.catch((error) => {
console.error(error);
resolve(null);
});
.then(({ data: resData, type }) => {
if (resData && type) {
const newNode = processNodeAdvancedFields(
resData,
edges,
nodeUpdate.id,
);
updates.push({
nodeId: nodeUpdate.id,
newNode,
code: currentCode,
name: "code",
type,
});
updatedCount++;
}
resolve(null);
})
.catch((error) => {
console.error(error);
resolve(null);
});
});
});
});
Promise.all(updatePromises)
.then(() => {
@ -144,50 +175,59 @@ export default function UpdateAllComponents({}: {}) {
};
};
if (componentsToUpdate.length === 0) return null;
if (componentsToUpdateFiltered.length === 0) return null;
return (
<div
className={cn(
"absolute bottom-2 left-1/2 z-50 flex w-[500px] -translate-x-1/2 items-center gap-8 rounded-lg bg-warning px-4 py-2 text-sm font-medium text-warning-foreground shadow-md transition-all ease-in",
"absolute bottom-2 left-1/2 z-50 flex w-[530px] -translate-x-1/2 items-center justify-between gap-8 rounded-lg border bg-background px-4 py-2 text-sm font-medium shadow-md transition-all ease-in",
dismissed && "translate-y-[120%]",
componentsToUpdateFiltered.some(
(component) => component.breakingChange,
) && "border-accent-amber-foreground",
)}
>
<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" : " is"} ready to update
Update
{componentsToUpdateFiltered.length > 1 ? "s are" : " is"} available
for{" "}
{componentsToUpdateFiltered.length +
" component" +
(componentsToUpdateFiltered.length > 1 ? "s" : "")}
</span>
</div>
<div className="flex items-center gap-4">
<Button
variant="link"
size="icon"
className="shrink-0 text-sm text-warning-foreground"
className="shrink-0 text-sm"
onClick={(e) => {
setDismissed(true);
setDismissAll(true);
addDismissedNodes(
componentsToUpdateFiltered.map((component) => component.id),
);
e.stopPropagation();
}}
>
Dismiss
Dismiss {componentsToUpdateFiltered.length > 1 ? "All" : ""}
</Button>
<Button
variant="warning"
size="sm"
className="shrink-0"
onClick={handleUpdateAllComponents}
onClick={() => handleUpdateAllComponents()}
loading={loadingUpdate}
data-testid="update-all-button"
>
Update All
{breakingChanges.length > 0 ? "Review All" : "Update All"}
</Button>
</div>
<UpdateComponentModal
isMultiple={true}
open={isOpen}
setOpen={setIsOpen}
onUpdateNode={(ids) => handleUpdateAllComponents(true, ids)}
components={componentsToUpdateFiltered}
/>
</div>
);
}

View file

@ -52,6 +52,8 @@ const NodeToolbarComponent = memo(
onCloseAdvancedModal,
updateNode,
isOutdated,
isUserEdited,
hasBreakingChange,
setOpenShowMoreOptions,
}: nodeToolbarPropsType): JSX.Element => {
const version = useDarkStore((state) => state.version);
@ -102,8 +104,6 @@ const NodeToolbarComponent = memo(
Object.values(flow).includes(data.node?.display_name!),
);
const setNode = useFlowStore((state) => state.setNode);
const nodeLength = useMemo(() => getNodeLength(data), [data]);
const hasCode = useMemo(
() => Object.keys(data.node!.template).includes("code"),
@ -606,8 +606,9 @@ const NodeToolbarComponent = memo(
shortcuts.find((obj) => obj.name === "Update")
?.shortcut!
}
value={"Restore"}
icon={"RefreshCcwDot"}
style={hasBreakingChange ? "text-warning" : ""}
value={isUserEdited ? "Restore" : "Update"}
icon={isUserEdited ? "RefreshCcwDot" : "CircleArrowUp"}
dataTestId="update-button-modal"
/>
</SelectItem>

View file

@ -43,8 +43,6 @@ export default function FlowPage({ view }: { view?: boolean }): JSX.Element {
const flows = useFlowsManagerStore((state) => state.flows);
const currentFlowId = useFlowsManagerStore((state) => state.currentFlowId);
const flowToCanvas = useFlowsManagerStore((state) => state.flowToCanvas);
const updatedAt = currentSavedFlow?.updated_at;
const autoSaving = useFlowsManagerStore((state) => state.autoSaving);
const stopBuilding = useFlowStore((state) => state.stopBuilding);
@ -112,19 +110,18 @@ export default function FlowPage({ view }: { view?: boolean }): JSX.Element {
const isAnExistingFlowId = isAnExistingFlow.id;
flowToCanvas
? setCurrentFlow(flowToCanvas)
: getFlowToAddToCanvas(isAnExistingFlowId);
await getFlowToAddToCanvas(isAnExistingFlowId);
}
};
awaitgetTypes();
}, [id, flows, currentFlowId, flowToCanvas]);
}, [id, flows, currentFlowId]);
useEffect(() => {
setOnFlowPage(true);
return () => {
setOnFlowPage(false);
console.log("unmounting");
setCurrentFlow(undefined);
};
}, [id]);
@ -152,6 +149,7 @@ export default function FlowPage({ view }: { view?: boolean }): JSX.Element {
const getFlowToAddToCanvas = async (id: string) => {
const flow = await getFlow({ id: id });
console.log(flow);
setCurrentFlow(flow);
};

View file

@ -3,7 +3,7 @@ import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import useAlertStore from "@/stores/alertStore";
import { FlowType } from "@/types/flow";
import { downloadFlow } from "@/utils/reactflowUtils";
import useDuplicateFlows from "../../hooks/use-handle-duplicate";
import useDuplicateFlow from "../../hooks/use-handle-duplicate";
import useSelectOptionsChange from "../../hooks/use-select-options-change";
type DropdownComponentProps = {
@ -21,11 +21,15 @@ const DropdownComponent = ({
const setSuccessData = useAlertStore((state) => state.setSuccessData);
const setErrorData = useAlertStore((state) => state.setErrorData);
const { handleDuplicate } = useDuplicateFlows({
selectedFlowsComponentsCards: [flowData.id],
allFlows: [flowData],
setSuccessData,
});
const { handleDuplicate } = useDuplicateFlow({ flow: flowData });
const duplicateFlow = () => {
handleDuplicate().then(() =>
setSuccessData({
title: `${flowData.is_component ? "Component" : "Flow"} duplicated successfully`,
}),
);
};
const handleExport = () => {
downloadFlow(flowData, flowData.name, flowData.description);
@ -35,7 +39,7 @@ const DropdownComponent = ({
[flowData.id],
setErrorData,
setOpenDelete,
handleDuplicate,
duplicateFlow,
handleExport,
handleEdit,
);

View file

@ -34,9 +34,6 @@ const GridComponent = ({ flowData }: { flowData: FlowType }) => {
const setErrorData = useAlertStore((state) => state.setErrorData);
const { folderId } = useParams();
const isComponent = flowData.is_component ?? false;
const setFlowToCanvas = useFlowsManagerStore(
(state) => state.setFlowToCanvas,
);
const { getIcon } = useGetTemplateStyle(flowData);
@ -50,7 +47,6 @@ const GridComponent = ({ flowData }: { flowData: FlowType }) => {
const handleClick = async () => {
if (!isComponent) {
await setFlowToCanvas(flowData);
navigate(editFlowLink);
}
};

View file

@ -33,16 +33,13 @@ const ListComponent = ({ flowData }: { flowData: FlowType }) => {
const { folderId } = useParams();
const [openSettings, setOpenSettings] = useState(false);
const isComponent = flowData.is_component ?? false;
const setFlowToCanvas = useFlowsManagerStore(
(state) => state.setFlowToCanvas,
);
const { getIcon } = useGetTemplateStyle(flowData);
const editFlowLink = `/flow/${flowData.id}${folderId ? `/folder/${folderId}` : ""}`;
const handleClick = async () => {
if (!isComponent) {
await setFlowToCanvas(flowData);
navigate(editFlowLink);
}
};

View file

@ -1,46 +1,31 @@
import { usePostAddFlow } from "@/controllers/API/queries/flows/use-post-add-flow";
import { useFolderStore } from "@/stores/foldersStore";
import { addVersionToDuplicates, createNewFlow } from "@/utils/reactflowUtils";
import { FlowType } from "@/types/flow";
import { createNewFlow } from "@/utils/reactflowUtils";
import { useParams } from "react-router-dom";
type UseDuplicateFlowsParams = {
selectedFlowsComponentsCards: string[];
allFlows: any[];
setSuccessData: (data: { title: string }) => void;
flow?: FlowType;
};
const useDuplicateFlows = ({
selectedFlowsComponentsCards,
allFlows,
setSuccessData,
}: UseDuplicateFlowsParams) => {
const useDuplicateFlow = ({ flow }: UseDuplicateFlowsParams) => {
const { mutateAsync: postAddFlow } = usePostAddFlow();
const { folderId } = useParams();
const myCollectionId = useFolderStore((state) => state.myCollectionId);
const handleDuplicate = async () => {
selectedFlowsComponentsCards.map(async (selectedFlow) => {
const currentFlow = allFlows.find((flow) => flow.id === selectedFlow);
if (flow?.data) {
const folder_id = folderId ?? myCollectionId ?? "";
const flowsToCheckNames = allFlows?.filter(
(f) => f.folder_id === folder_id,
);
const newFlow = createNewFlow(flow.data, folder_id, flow);
const newFlow = createNewFlow(currentFlow.data, folder_id, currentFlow);
const newName = addVersionToDuplicates(newFlow, flowsToCheckNames ?? []);
newFlow.name = newName;
newFlow.folder_id = folder_id;
await postAddFlow(newFlow);
setSuccessData({
title: `${newFlow.is_component ? "Component" : "Flow"} duplicated successfully`,
});
});
}
};
return { handleDuplicate };
};
export default useDuplicateFlows;
export default useDuplicateFlow;

View file

@ -1,13 +1,11 @@
import {
BROKEN_EDGES_WARNING,
componentsToIgnoreUpdate,
} from "@/constants/constants";
import { BROKEN_EDGES_WARNING } from "@/constants/constants";
import { ENABLE_DATASTAX_LANGFLOW } from "@/customization/feature-flags";
import {
track,
trackDataLoaded,
trackFlowBuild,
} from "@/customization/utils/analytics";
import { checkCodeValidity } from "@/CustomNodes/helpers/check-code-validity";
import { brokenEdgeMessage } from "@/utils/utils";
import {
EdgeChange,
@ -33,7 +31,11 @@ import {
sourceHandleType,
targetHandleType,
} from "../types/flow";
import { FlowStoreType, VertexLayerElementType } from "../types/zustand/flow";
import {
ComponentsToUpdateType,
FlowStoreType,
VertexLayerElementType,
} from "../types/zustand/flow";
import { buildFlowVerticesWithFallback } from "../utils/buildUtils";
import {
buildPositionDictionary,
@ -88,24 +90,22 @@ const useFlowStore = create<FlowStoreType>((set, get) => ({
set({ componentsToUpdate: newChange });
},
updateComponentsToUpdate: (nodes) => {
let outdatedNodes: string[] = [];
let outdatedNodes: ComponentsToUpdateType[] = [];
const templates = useTypesStore.getState().templates;
for (let i = 0; i < nodes.length; i++) {
let node = nodes[i];
nodes.forEach((node) => {
if (node.type === "genericNode") {
const currentCode = templates[node.data?.type]?.template?.code?.value;
const thisNodesCode = node.data?.node!.template?.code?.value;
if (
currentCode &&
thisNodesCode &&
currentCode !== thisNodesCode &&
!node.data?.node?.edited &&
!componentsToIgnoreUpdate.includes(node.data?.type)
) {
outdatedNodes.push(node.id);
}
const codeValidity = checkCodeValidity(node.data, templates);
if (codeValidity && codeValidity.outdated)
outdatedNodes.push({
id: node.id,
icon: node.data.node?.icon,
display_name: node.data.node?.display_name,
outdated: codeValidity.outdated,
breakingChange: codeValidity.breakingChange,
userEdited: codeValidity.userEdited,
});
}
}
});
set({ componentsToUpdate: outdatedNodes });
},
onFlowPage: false,
@ -223,6 +223,11 @@ const useFlowStore = create<FlowStoreType>((set, get) => ({
let newEdges = cleanEdges(nodes, edges);
const { inputs, outputs } = getInputsAndOutputs(nodes);
get().updateComponentsToUpdate(nodes);
set({
dismissedNodes: JSON.parse(
localStorage.getItem(`dismiss_${flow?.id}`) ?? "[]",
) as string[],
});
unselectAllNodesEdges(nodes, edges);
set({
nodes,
@ -992,6 +997,27 @@ const useFlowStore = create<FlowStoreType>((set, get) => ({
componentsToUpdate: [],
});
},
dismissedNodes: [],
addDismissedNodes: (dismissedNodes: string[]) => {
const newDismissedNodes = Array.from(
new Set([...get().dismissedNodes, ...dismissedNodes]),
);
localStorage.setItem(
`dismiss_${get().currentFlow?.id}`,
JSON.stringify(newDismissedNodes),
);
set({ dismissedNodes: newDismissedNodes });
},
removeDismissedNodes: (dismissedNodes: string[]) => {
const newDismissedNodes = get().dismissedNodes.filter(
(node) => !dismissedNodes.includes(node),
);
localStorage.setItem(
`dismiss_${get().currentFlow?.id}`,
JSON.stringify(newDismissedNodes),
);
set({ dismissedNodes: newDismissedNodes });
},
}));
export default useFlowStore;

View file

@ -130,19 +130,11 @@ const useFlowsManagerStore = create<FlowsManagerStoreType>((set, get) => ({
setSelectedFlowsComponentsCards: (selectedFlowsComponentsCards: string[]) => {
set({ selectedFlowsComponentsCards });
},
flowToCanvas: null,
setFlowToCanvas: async (flowToCanvas: FlowType | null) => {
await new Promise<void>((resolve) => {
set({ flowToCanvas });
resolve();
});
},
resetStore: () => {
set({
flows: [],
currentFlow: undefined,
currentFlowId: "",
flowToCanvas: null,
searchFlowsComponents: "",
selectedFlowsComponentsCards: [],
});

View file

@ -6,8 +6,6 @@ import { create } from "zustand";
export const useUtilityStore = create<UtilityStoreType>((set, get) => ({
clientId: "",
setClientId: (clientId: string) => set({ clientId }),
dismissAll: false,
setDismissAll: (dismissAll: boolean) => set({ dismissAll }),
chatValueStore: "",
setChatValueStore: (value: string) => set({ chatValueStore: value }),
selectedItems: [],

View file

@ -135,10 +135,19 @@
padding: 0rem 1rem !important;
}
.ag-tool-mode .ag-row-focus {
.ag-tool-mode:not(.ag-no-selection) .ag-row-focus {
background-color: hsl(var(--accent)) !important;
}
.ag-tool-mode .ag-row-selected:not(.ag-row-focus)::before {
.ag-tool-mode.ag-no-selection .ag-row-selected::before {
background-color: hsl(var(--background)) !important;
}
.ag-tool-mode .ag-checkbox-input-wrapper:focus-within,
.ag-tool-mode .ag-checkbox-input-wrapper:active {
box-shadow: none !important;
}
.ag-tool-mode .ag-layout-auto-height .ag-center-cols-container,
.ag-tool-mode .ag-layout-auto-height .ag-center-cols-viewport {
min-height: 0px !important;
}

View file

@ -104,6 +104,7 @@ export type OutputFieldType = {
types: Array<string>;
selected?: string;
name: string;
method?: string;
display_name: string;
hidden?: boolean;
proxy?: OutputFieldProxyType;

View file

@ -583,6 +583,8 @@ export type nodeToolbarPropsType = {
openAdvancedModal?: boolean;
onCloseAdvancedModal?: (close: boolean) => void;
isOutdated: boolean;
isUserEdited: boolean;
hasBreakingChange: boolean;
updateNode: () => void;
closeToolbar?: () => void;
setOpenShowMoreOptions?: (open: boolean) => void;

View file

@ -52,7 +52,19 @@ export type FlowPoolType = {
[key: string]: Array<VertexBuildTypeAPI>;
};
export type ComponentsToUpdateType = {
id: string;
icon?: string;
display_name: string;
outdated: boolean;
breakingChange: boolean;
userEdited: boolean;
};
export type FlowStoreType = {
dismissedNodes: string[];
addDismissedNodes: (dismissedNodes: string[]) => void;
removeDismissedNodes: (dismissedNodes: string[]) => void;
//key x, y
positionDictionary: { [key: number]: number };
isPositionAvailable: (position: { x: number; y: number }) => boolean;
@ -61,9 +73,11 @@ export type FlowStoreType = {
}) => void;
fitViewNode: (nodeId: string) => void;
autoSaveFlow: (() => void) | undefined;
componentsToUpdate: string[];
componentsToUpdate: ComponentsToUpdateType[];
setComponentsToUpdate: (
update: string[] | ((oldState: string[]) => string[]),
update:
| ComponentsToUpdateType[]
| ((oldState: ComponentsToUpdateType[]) => ComponentsToUpdateType[]),
) => void;
updateComponentsToUpdate: (nodes: AllNodeType[]) => void;
onFlowPage: boolean;

View file

@ -26,8 +26,6 @@ export type FlowsManagerStoreType = {
setAutoSavingInterval: (autoSavingInterval: number) => void;
healthCheckMaxRetries: number;
setHealthCheckMaxRetries: (healthCheckMaxRetries: number) => void;
flowToCanvas: FlowType | null;
setFlowToCanvas: (flowToCanvas: FlowType | null) => Promise<void>;
IOModalOpen: boolean;
setIOModalOpen: (IOModalOpen: boolean) => void;
resetStore: () => void;

View file

@ -9,7 +9,6 @@ export type UtilityStoreType = {
playgroundScrollBehaves: ScrollBehavior;
setPlaygroundScrollBehaves: (behaves: ScrollBehavior) => void;
maxFileSizeUpload: number;
setMaxFileSizeUpload: (maxFileSizeUpload: number) => void;
flowsPagination: Pagination;
setFlowsPagination: (pagination: Pagination) => void;
tags: Tag[];
@ -20,8 +19,6 @@ export type UtilityStoreType = {
setWebhookPollingInterval: (webhookPollingInterval: number) => void;
chatValueStore: string;
setChatValueStore: (value: string) => void;
dismissAll: boolean;
setDismissAll: (dismissAll: boolean) => void;
currentSessionId: string;
setCurrentSessionId: (sessionId: string) => void;
setClientId: (clientId: string) => void;

File diff suppressed because one or more lines are too long

View file

@ -2,7 +2,9 @@ import { expect, test } from "@playwright/test";
import { readFileSync } from "fs";
import { awaitBootstrapTest } from "../../utils/await-bootstrap-test";
test("user must be able to update outdated components", async ({ page }) => {
test("user must be able to update outdated components by update all button", async ({
page,
}) => {
await awaitBootstrapTest(page);
await page.locator("span").filter({ hasText: "Close" }).first().click();
@ -27,22 +29,152 @@ test("user must be able to update outdated components", async ({ page }) => {
dataTransfer,
});
await page.waitForTimeout(1000);
await page.waitForSelector("data-testid=list-card", {
timeout: 3000,
});
await page.getByTestId("list-card").first().click();
await expect(page.getByText("components are ready to update")).toBeVisible({
await expect(page.getByText("Updates are available for 5")).toBeVisible({
timeout: 30000,
});
let outdatedComponents = await page.getByTestId("icon-AlertTriangle").count();
expect(outdatedComponents).toBeGreaterThan(0);
let outdatedComponents = await page.getByTestId("update-button").count();
expect(outdatedComponents).toBe(1);
await page.getByText("Update All", { exact: true }).click();
let outdatedBreakingComponents = await page
.getByTestId("review-button")
.count();
expect(outdatedBreakingComponents).toBe(4);
await expect(page.getByTestId("icon-AlertTriangle")).toHaveCount(0, {
expect(await page.getByTestId("update-all-button")).toHaveText("Review All");
await page.getByTestId("update-all-button").click();
expect(
await page.locator('input[data-ref="eInput"]').nth(2).isChecked(),
).toBe(false);
expect(
await page.locator('input[data-ref="eInput"]').nth(3).isChecked(),
).toBe(false);
expect(
await page.locator('input[data-ref="eInput"]').nth(4).isChecked(),
).toBe(true);
expect(
await page.locator('input[data-ref="eInput"]').nth(5).isChecked(),
).toBe(false);
expect(
await page.locator('input[data-ref="eInput"]').nth(6).isChecked(),
).toBe(false);
await page
.getByRole("checkbox", { name: "Column with Header Selection" })
.check();
expect(await page.getByTestId("backup-flow-checkbox").isChecked()).toBe(true);
await page.getByTestId("backup-flow-checkbox").click();
await page.getByRole("button", { name: "Update Components" }).click();
await expect(page.getByTestId("update-button")).toHaveCount(0, {
timeout: 5000,
});
await expect(page.getByTestId("review-button")).toHaveCount(0, {
timeout: 5000,
});
});
test("user must be able to update outdated components by each outdated component", async ({
page,
}) => {
await awaitBootstrapTest(page);
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(1000);
await page.waitForSelector("data-testid=list-card", {
timeout: 3000,
});
await page.getByTestId("list-card").first().click();
await expect(page.getByText("Updates are available for 5")).toBeVisible({
timeout: 30000,
});
let outdatedComponents = await page.getByTestId("update-button").count();
expect(outdatedComponents).toBe(1);
let outdatedBreakingComponents = await page
.getByTestId("review-button")
.count();
expect(outdatedBreakingComponents).toBe(4);
expect(await page.getByTestId("update-all-button")).toHaveText("Review All");
await page.getByTestId("review-button").first().click();
await page.waitForSelector("button[data-testid='backup-flow-checkbox']", {
timeout: 30000,
});
expect(await page.getByTestId("backup-flow-checkbox").isChecked()).toBe(true);
await page.getByRole("button", { name: "Update Component" }).click();
await expect(page.getByTestId("update-button")).toHaveCount(1, {
timeout: 5000,
});
await expect(page.getByTestId("review-button")).toHaveCount(3, {
timeout: 5000,
});
await expect(page.getByText("Updates are available for 4")).toBeVisible({
timeout: 30000,
});
expect(await page.getByTestId("update-all-button")).toHaveText("Review All");
await page.getByTestId("update-button").first().click();
await expect(page.getByTestId("update-button")).toHaveCount(0, {
timeout: 5000,
});
await expect(page.getByTestId("review-button")).toHaveCount(3, {
timeout: 5000,
});
await awaitBootstrapTest(page, { skipModal: true });
await expect(page.getByText("Backup").count()).toBeGreaterThan(0);
});