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, showNode,
data, data,
buildStatus, buildStatus,
dismissAll,
isOutdated, isOutdated,
isUserEdited, isUserEdited,
isBreakingChange,
getValidationStatus, getValidationStatus,
handleUpdateComponent,
}: { }: {
nodeId: string; nodeId: string;
display_name: string; display_name: string;
@ -52,10 +53,11 @@ export default function NodeStatus({
showNode: boolean; showNode: boolean;
data: NodeDataType; data: NodeDataType;
buildStatus: BuildStatus; buildStatus: BuildStatus;
dismissAll: boolean;
isOutdated: boolean; isOutdated: boolean;
isUserEdited: boolean; isUserEdited: boolean;
isBreakingChange: boolean;
getValidationStatus: (data) => VertexBuildTypeAPI | null; getValidationStatus: (data) => VertexBuildTypeAPI | null;
handleUpdateComponent: () => void;
}) { }) {
const nodeId_ = data.node?.flow?.data const nodeId_ = data.node?.flow?.data
? (findLastNode(data.node?.flow.data!)?.id ?? nodeId) ? (findLastNode(data.node?.flow.data!)?.id ?? nodeId)
@ -182,8 +184,6 @@ export default function NodeStatus({
getValidationStatus, getValidationStatus,
); );
const dismissAll = useUtilityStore((state) => state.dismissAll);
const getBaseBorderClass = (selected) => { const getBaseBorderClass = (selected) => {
let className = let className =
selected && !isBuilding selected && !isBuilding
@ -191,8 +191,8 @@ export default function NodeStatus({
: "border ring-[0.5px] hover:shadow-node ring-border"; : "border ring-[0.5px] hover:shadow-node ring-border";
let frozenClass = selected ? "border-ring-frozen" : "border-frozen"; let frozenClass = selected ? "border-ring-frozen" : "border-frozen";
let updateClass = let updateClass =
isOutdated && !isUserEdited && !dismissAll isOutdated && !isUserEdited && !dismissAll && isBreakingChange
? "border-warning ring-2 ring-warning" ? "border-warning"
: ""; : "";
return cn(frozen ? frozenClass : className, updateClass); return cn(frozen ? frozenClass : className, updateClass);
}; };
@ -464,36 +464,6 @@ export default function NodeStatus({
)} )}
</div> </div>
</ShadTooltip> </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> </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 ForwardedIconComponent from "@/components/common/genericIconComponent";
import ShadTooltip from "@/components/common/shadTooltipComponent"; import ShadTooltip from "@/components/common/shadTooltipComponent";
import { usePostValidateComponentCode } from "@/controllers/API/queries/nodes/use-post-validate-component-code"; 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 { useAlternate } from "@/shared/hooks/use-alternate";
import { useUtilityStore } from "@/stores/utilityStore"; import { FlowStoreType } from "@/types/zustand/flow";
import { useUpdateNodeInternals } from "@xyflow/react"; import { useUpdateNodeInternals } from "@xyflow/react";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useHotkeys } from "react-hotkeys-hook"; import { useHotkeys } from "react-hotkeys-hook";
import { useShallow } from "zustand/react/shallow";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { import {
ICON_STROKE_WIDTH, ICON_STROKE_WIDTH,
@ -24,12 +26,12 @@ import { NodeDataType } from "../../types/flow";
import { checkHasToolMode } from "../../utils/reactflowUtils"; import { checkHasToolMode } from "../../utils/reactflowUtils";
import { classNames, cn } from "../../utils/utils"; import { classNames, cn } from "../../utils/utils";
import { processNodeAdvancedFields } from "../helpers/process-node-advanced-fields"; 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 useUpdateNodeCode from "../hooks/use-update-node-code";
import NodeDescription from "./components/NodeDescription"; import NodeDescription from "./components/NodeDescription";
import NodeName from "./components/NodeName"; import NodeName from "./components/NodeName";
import { OutputParameter } from "./components/NodeOutputParameter"; import { OutputParameter } from "./components/NodeOutputParameter";
import NodeStatus from "./components/NodeStatus"; import NodeStatus from "./components/NodeStatus";
import NodeUpdateComponent from "./components/NodeUpdateComponent";
import RenderInputParameters from "./components/RenderInputParameters"; import RenderInputParameters from "./components/RenderInputParameters";
import { NodeIcon } from "./components/nodeIcon"; import { NodeIcon } from "./components/nodeIcon";
import { useBuildStatus } from "./hooks/use-get-build-status"; import { useBuildStatus } from "./hooks/use-get-build-status";
@ -72,13 +74,12 @@ function GenericNode({
xPos?: number; xPos?: number;
yPos?: number; yPos?: number;
}): JSX.Element { }): JSX.Element {
const [isOutdated, setIsOutdated] = useState(false);
const [isUserEdited, setIsUserEdited] = useState(false);
const [borderColor, setBorderColor] = useState<string>(""); const [borderColor, setBorderColor] = useState<string>("");
const [loadingUpdate, setLoadingUpdate] = useState(false); const [loadingUpdate, setLoadingUpdate] = useState(false);
const [showHiddenOutputs, setShowHiddenOutputs] = useState(false); const [showHiddenOutputs, setShowHiddenOutputs] = useState(false);
const [validationStatus, setValidationStatus] = const [validationStatus, setValidationStatus] =
useState<VertexBuildTypeAPI | null>(null); useState<VertexBuildTypeAPI | null>(null);
const [openUpdateModal, setOpenUpdateModal] = useState(false);
const types = useTypesStore((state) => state.types); const types = useTypesStore((state) => state.types);
const templates = useTypesStore((state) => state.templates); const templates = useTypesStore((state) => state.templates);
@ -90,7 +91,15 @@ function GenericNode({
const edges = useFlowStore((state) => state.edges); const edges = useFlowStore((state) => state.edges);
const shortcuts = useShortcutsStore((state) => state.shortcuts); const shortcuts = useShortcutsStore((state) => state.shortcuts);
const buildStatus = useBuildStatus(data, data.id); 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; const showNode = data.showNode ?? true;
@ -104,16 +113,31 @@ function GenericNode({
const [editNameDescription, toggleEditNameDescription, set] = const [editNameDescription, toggleEditNameDescription, set] =
useAlternate(false); 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( const updateNodeCode = useUpdateNodeCode(
data?.id, data?.id,
data.node!, data.node!,
setNode, setNode,
setIsOutdated,
setIsUserEdited,
updateNodeInternals, updateNodeInternals,
); );
useCheckCodeValidity(data, templates, setIsOutdated, setIsUserEdited, types); useEffect(() => {
updateNodeInternals(data.id);
}, [data.node.template]);
if (!data.node!.template) { if (!data.node!.template) {
setErrorData({ setErrorData({
@ -127,7 +151,12 @@ function GenericNode({
deleteNode(data.id); deleteNode(data.id);
} }
const handleUpdateCode = useCallback(() => { const handleUpdateCode = useCallback(
(confirmed: boolean = false) => {
if (!confirmed && hasBreakingChange) {
setOpenUpdateModal(true);
return;
}
setLoadingUpdate(true); setLoadingUpdate(true);
takeSnapshot(); takeSnapshot();
@ -147,6 +176,7 @@ function GenericNode({
data.id, data.id,
); );
updateNodeCode(newNode, currentCode, "code", type); updateNodeCode(newNode, currentCode, "code", type);
removeDismissedNodes([data.id]);
setLoadingUpdate(false); setLoadingUpdate(false);
} }
}, },
@ -164,15 +194,18 @@ function GenericNode({
}, },
); );
} }
}, [ },
[
data, data,
templates, templates,
hasBreakingChange,
edges, edges,
updateNodeCode, updateNodeCode,
validateComponentCode, validateComponentCode,
setErrorData, setErrorData,
takeSnapshot, takeSnapshot,
]); ],
);
const handleUpdateCodeWShortcut = useCallback(() => { const handleUpdateCodeWShortcut = useCallback(() => {
if (isOutdated && selected) { if (isOutdated && selected) {
@ -194,11 +227,6 @@ function GenericNode({
[data.node?.outputs, data.node?.tool_mode], [data.node?.outputs, data.node?.tool_mode],
); );
const hasToolMode = useMemo(
() => checkHasToolMode(data.node?.template ?? {}),
[data.node?.template],
);
const hasOutputs = useMemo( const hasOutputs = useMemo(
() => data.node?.outputs && data.node.outputs.length > 0, () => data.node?.outputs && data.node.outputs.length > 0,
[data.node?.outputs], [data.node?.outputs],
@ -270,6 +298,11 @@ function GenericNode({
return useFlowStore.getState().nodes.filter((node) => node.selected).length; return useFlowStore.getState().nodes.filter((node) => node.selected).length;
}, [selected]); }, [selected]);
const shouldShowUpdateComponent = useMemo(
() => (isOutdated || hasBreakingChange) && !isUserEdited && !dismissAll,
[isOutdated, hasBreakingChange, isUserEdited, dismissAll],
);
const memoizedNodeToolbarComponent = useMemo(() => { const memoizedNodeToolbarComponent = useMemo(() => {
return selected && selectedNodesCount === 1 ? ( return selected && selectedNodesCount === 1 ? (
<> <>
@ -295,8 +328,10 @@ function GenericNode({
showNode={showNode} showNode={showNode}
openAdvancedModal={false} openAdvancedModal={false}
onCloseAdvancedModal={() => {}} onCloseAdvancedModal={() => {}}
updateNode={handleUpdateCode} updateNode={() => handleUpdateCode()}
isOutdated={isOutdated && isUserEdited} isOutdated={isOutdated && dismissAll}
isUserEdited={isUserEdited}
hasBreakingChange={hasBreakingChange}
/> />
</div> </div>
<div className="-z-10"> <div className="-z-10">
@ -410,10 +445,11 @@ function GenericNode({
selected={selected} selected={selected}
setBorderColor={setBorderColor} setBorderColor={setBorderColor}
buildStatus={buildStatus} buildStatus={buildStatus}
dismissAll={dismissAll}
isOutdated={isOutdated} isOutdated={isOutdated}
isUserEdited={isUserEdited} isUserEdited={isUserEdited}
isBreakingChange={hasBreakingChange}
getValidationStatus={getValidationStatus} getValidationStatus={getValidationStatus}
handleUpdateComponent={handleUpdateCode}
/> />
); );
}, [ }, [
@ -463,42 +499,30 @@ function GenericNode({
}, [data, types, isToolMode, showNode, shownOutputs, showHiddenOutputs]); }, [data, types, isToolMode, showNode, shownOutputs, showHiddenOutputs]);
return ( return (
<div <div className={cn(shouldShowUpdateComponent ? "relative -mt-10" : "")}>
className={cn(
isOutdated && !isUserEdited && !dismissAll ? "relative -mt-10" : "",
)}
>
<div <div
className={cn( className={cn(
borderColor, borderColor,
showNode ? "w-80" : `w-48`, 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", !hasOutputs && "pb-4",
)} )}
> >
{memoizedNodeToolbarComponent} <UpdateComponentModal
{isOutdated && !isUserEdited && !dismissAll && ( open={openUpdateModal}
<div className="flex h-10 w-full items-center gap-4 rounded-t-[0.69rem] bg-warning p-2 px-4 text-warning-foreground"> setOpen={setOpenUpdateModal}
<ForwardedIconComponent onUpdateNode={() => handleUpdateCode(true)}
name="AlertTriangle" components={componentUpdate ? [componentUpdate] : []}
strokeWidth={ICON_STROKE_WIDTH} />
className="icon-size shrink-0" {memoizedNodeToolbarComponent}
{shouldShowUpdateComponent && (
<NodeUpdateComponent
hasBreakingChange={hasBreakingChange}
showNode={showNode}
handleUpdateCode={() => handleUpdateCode()}
loadingUpdate={loadingUpdate}
setDismissAll={() => addDismissedNodes([data.id])}
/> />
<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>
)} )}
<div <div
data-testid={`${data.id}-main-node`} data-testid={`${data.id}-main-node`}
@ -529,8 +553,7 @@ function GenericNode({
{!showNode && ( {!showNode && (
<> <>
{renderInputParameters()} {renderInputParameters()}
{shownOutputs && {shownOutputs.length > 0 &&
shownOutputs.length > 0 &&
renderOutputs(shownOutputs, "render-outputs")} 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, dataId: string,
dataNode: APIClassType, // Define YourNodeType according to your data structure dataNode: APIClassType, // Define YourNodeType according to your data structure
setNode: (id: string, callback: (oldNode) => any) => void, setNode: (id: string, callback: (oldNode) => any) => void,
setIsOutdated: (value: boolean) => void,
setIsUserEdited: (value: boolean) => void,
updateNodeInternals: (id: string) => void, updateNodeInternals: (id: string) => void,
) => { ) => {
const { setComponentsToUpdate } = useFlowStore(); const { setComponentsToUpdate } = useFlowStore();
@ -30,8 +28,6 @@ const useUpdateNodeCode = (
} }
newNode.data.node.template[name].value = code; newNode.data.node.template[name].value = code;
setIsOutdated(false);
setIsUserEdited(false);
const outputs = dataNode.outputs; const outputs = dataNode.outputs;
const updatedOutputs = newNodeClass.outputs; const updatedOutputs = newNodeClass.outputs;
@ -44,10 +40,12 @@ const useUpdateNodeCode = (
return newNode; return newNode;
}); });
setComponentsToUpdate((old) => old.filter((id) => id !== dataId)); setComponentsToUpdate((old) =>
old.filter((component) => component.id !== dataId),
);
updateNodeInternals(dataId); updateNodeInternals(dataId);
}, },
[dataId, dataNode, setNode, setIsOutdated, updateNodeInternals], [dataId, dataNode, setNode, updateNodeInternals],
); );
return updateNodeCode; return updateNodeCode;

View file

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

View file

@ -28,7 +28,6 @@ const useAddFlow = () => {
const setFlows = useFlowsManagerStore((state) => state.setFlows); const setFlows = useFlowsManagerStore((state) => state.setFlows);
const { deleteFlow } = useDeleteFlow(); const { deleteFlow } = useDeleteFlow();
const { setFlowToCanvas } = useFlowsManagerStore();
const setNoticeData = useAlertStore.getState().setNoticeData; const setNoticeData = useAlertStore.getState().setNoticeData;
const { folderId } = useParams(); const { folderId } = useParams();
const myCollectionId = useFolderStore((state) => state.myCollectionId); const myCollectionId = useFolderStore((state) => state.myCollectionId);
@ -93,7 +92,6 @@ const useAddFlow = () => {
}), }),
})); }));
setFlowToCanvas(createdFlow);
resolve(createdFlow.id); resolve(createdFlow.id);
}, },
onError: (error) => { 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]"; minWidth = "min-w-[40vw]";
height = ""; height = "";
break; break;
case "small-update":
minWidth = "min-w-[480px] max-w-[480px]";
height = "";
break;
case "small": case "small":
minWidth = "min-w-[40vw]"; minWidth = "min-w-[40vw]";
height = "h-[40vh]"; height = "h-[40vh]";

View file

@ -172,6 +172,7 @@ interface BaseModalProps {
| "retangular" | "retangular"
| "smaller" | "smaller"
| "small" | "small"
| "small-update"
| "small-query" | "small-query"
| "medium" | "medium"
| "medium-tall" | "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 { Button } from "@/components/ui/button";
import { usePostValidateComponentCode } from "@/controllers/API/queries/nodes/use-post-validate-component-code"; import { usePostValidateComponentCode } from "@/controllers/API/queries/nodes/use-post-validate-component-code";
import { processNodeAdvancedFields } from "@/CustomNodes/helpers/process-node-advanced-fields"; import { processNodeAdvancedFields } from "@/CustomNodes/helpers/process-node-advanced-fields";
import useUpdateAllNodes, { import useUpdateAllNodes, {
UpdateNodesType, UpdateNodesType,
} from "@/CustomNodes/hooks/use-update-all-nodes"; } from "@/CustomNodes/hooks/use-update-all-nodes";
import UpdateComponentModal from "@/modals/updateComponentModal";
import useAlertStore from "@/stores/alertStore"; import useAlertStore from "@/stores/alertStore";
import useFlowsManagerStore from "@/stores/flowsManagerStore"; import useFlowsManagerStore from "@/stores/flowsManagerStore";
import useFlowStore from "@/stores/flowStore"; import useFlowStore from "@/stores/flowStore";
import { useTypesStore } from "@/stores/typesStore"; import { useTypesStore } from "@/stores/typesStore";
import { useUtilityStore } from "@/stores/utilityStore";
import { cn } from "@/utils/utils"; import { cn } from "@/utils/utils";
import { useUpdateNodeInternals } from "@xyflow/react"; 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 = "Error updating components";
const ERROR_MESSAGE_UPDATING_COMPONENTS_LIST = [ const ERROR_MESSAGE_UPDATING_COMPONENTS_LIST = [
@ -24,7 +23,6 @@ const ERROR_MESSAGE_EDGES_LOST =
export default function UpdateAllComponents({}: {}) { export default function UpdateAllComponents({}: {}) {
const { componentsToUpdate, nodes, edges, setNodes } = useFlowStore(); const { componentsToUpdate, nodes, edges, setNodes } = useFlowStore();
const setDismissAll = useUtilityStore((state) => state.setDismissAll);
const templates = useTypesStore((state) => state.templates); const templates = useTypesStore((state) => state.templates);
const setErrorData = useAlertStore((state) => state.setErrorData); const setErrorData = useAlertStore((state) => state.setErrorData);
const { mutateAsync: validateComponentCode } = usePostValidateComponentCode(); const { mutateAsync: validateComponentCode } = usePostValidateComponentCode();
@ -34,7 +32,26 @@ export default function UpdateAllComponents({}: {}) {
const updateAllNodes = useUpdateAllNodes(setNodes, updateNodeInternals); const updateAllNodes = useUpdateAllNodes(setNodes, updateNodeInternals);
const [loadingUpdate, setLoadingUpdate] = useState(false); 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({ const edgesUpdateRef = useRef({
numberOfEdgesBeforeUpdate: 0, 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(); startEdgesUpdateRef();
setLoadingUpdate(true); setLoadingUpdate(true);
@ -71,8 +96,10 @@ export default function UpdateAllComponents({}: {}) {
let updatedCount = 0; let updatedCount = 0;
const updates: UpdateNodesType[] = []; const updates: UpdateNodesType[] = [];
const updatePromises = componentsToUpdate.map((nodeId) => { const updatePromises = componentsToUpdateFiltered
const node = nodes.find((n) => n.id === nodeId); .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(); if (!node || node.type !== "genericNode") return Promise.resolve();
const thisNodeTemplate = templates[node.data.type]?.template; const thisNodeTemplate = templates[node.data.type]?.template;
@ -87,10 +114,14 @@ export default function UpdateAllComponents({}: {}) {
}) })
.then(({ data: resData, type }) => { .then(({ data: resData, type }) => {
if (resData && type) { if (resData && type) {
const newNode = processNodeAdvancedFields(resData, edges, nodeId); const newNode = processNodeAdvancedFields(
resData,
edges,
nodeUpdate.id,
);
updates.push({ updates.push({
nodeId, nodeId: nodeUpdate.id,
newNode, newNode,
code: currentCode, code: currentCode,
name: "code", name: "code",
@ -144,50 +175,59 @@ export default function UpdateAllComponents({}: {}) {
}; };
}; };
if (componentsToUpdate.length === 0) return null; if (componentsToUpdateFiltered.length === 0) return null;
return ( return (
<div <div
className={cn( 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%]", dismissed && "translate-y-[120%]",
componentsToUpdateFiltered.some(
(component) => component.breakingChange,
) && "border-accent-amber-foreground",
)} )}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<ForwardedIconComponent
name="AlertTriangle"
className="!h-[18px] !w-[18px] shrink-0"
strokeWidth={1.5}
/>
<span> <span>
{componentsToUpdate.length} component Update
{componentsToUpdate.length > 1 ? "s are" : " is"} ready to update {componentsToUpdateFiltered.length > 1 ? "s are" : " is"} available
for{" "}
{componentsToUpdateFiltered.length +
" component" +
(componentsToUpdateFiltered.length > 1 ? "s" : "")}
</span> </span>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button <Button
variant="link" variant="link"
size="icon" size="icon"
className="shrink-0 text-sm text-warning-foreground" className="shrink-0 text-sm"
onClick={(e) => { onClick={(e) => {
setDismissed(true); addDismissedNodes(
setDismissAll(true); componentsToUpdateFiltered.map((component) => component.id),
);
e.stopPropagation(); e.stopPropagation();
}} }}
> >
Dismiss Dismiss {componentsToUpdateFiltered.length > 1 ? "All" : ""}
</Button> </Button>
<Button <Button
variant="warning"
size="sm" size="sm"
className="shrink-0" className="shrink-0"
onClick={handleUpdateAllComponents} onClick={() => handleUpdateAllComponents()}
loading={loadingUpdate} loading={loadingUpdate}
data-testid="update-all-button" data-testid="update-all-button"
> >
Update All {breakingChanges.length > 0 ? "Review All" : "Update All"}
</Button> </Button>
</div> </div>
<UpdateComponentModal
isMultiple={true}
open={isOpen}
setOpen={setIsOpen}
onUpdateNode={(ids) => handleUpdateAllComponents(true, ids)}
components={componentsToUpdateFiltered}
/>
</div> </div>
); );
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -135,10 +135,19 @@
padding: 0rem 1rem !important; 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; background-color: hsl(var(--accent)) !important;
} }
.ag-tool-mode.ag-no-selection .ag-row-selected::before {
.ag-tool-mode .ag-row-selected:not(.ag-row-focus)::before {
background-color: hsl(var(--background)) !important; 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>; types: Array<string>;
selected?: string; selected?: string;
name: string; name: string;
method?: string;
display_name: string; display_name: string;
hidden?: boolean; hidden?: boolean;
proxy?: OutputFieldProxyType; proxy?: OutputFieldProxyType;

View file

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

View file

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

View file

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

View file

@ -9,7 +9,6 @@ export type UtilityStoreType = {
playgroundScrollBehaves: ScrollBehavior; playgroundScrollBehaves: ScrollBehavior;
setPlaygroundScrollBehaves: (behaves: ScrollBehavior) => void; setPlaygroundScrollBehaves: (behaves: ScrollBehavior) => void;
maxFileSizeUpload: number; maxFileSizeUpload: number;
setMaxFileSizeUpload: (maxFileSizeUpload: number) => void;
flowsPagination: Pagination; flowsPagination: Pagination;
setFlowsPagination: (pagination: Pagination) => void; setFlowsPagination: (pagination: Pagination) => void;
tags: Tag[]; tags: Tag[];
@ -20,8 +19,6 @@ export type UtilityStoreType = {
setWebhookPollingInterval: (webhookPollingInterval: number) => void; setWebhookPollingInterval: (webhookPollingInterval: number) => void;
chatValueStore: string; chatValueStore: string;
setChatValueStore: (value: string) => void; setChatValueStore: (value: string) => void;
dismissAll: boolean;
setDismissAll: (dismissAll: boolean) => void;
currentSessionId: string; currentSessionId: string;
setCurrentSessionId: (sessionId: string) => void; setCurrentSessionId: (sessionId: string) => void;
setClientId: (clientId: 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 { readFileSync } from "fs";
import { awaitBootstrapTest } from "../../utils/await-bootstrap-test"; 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 awaitBootstrapTest(page);
await page.locator("span").filter({ hasText: "Close" }).first().click(); 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, dataTransfer,
}); });
await page.waitForTimeout(1000);
await page.waitForSelector("data-testid=list-card", { await page.waitForSelector("data-testid=list-card", {
timeout: 3000, timeout: 3000,
}); });
await page.getByTestId("list-card").first().click(); 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, timeout: 30000,
}); });
let outdatedComponents = await page.getByTestId("icon-AlertTriangle").count(); let outdatedComponents = await page.getByTestId("update-button").count();
expect(outdatedComponents).toBeGreaterThan(0); 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, 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);
});