From 79e35834b520d52c4967989bf60e2ab35568d9df Mon Sep 17 00:00:00 2001 From: Lucas Oliveira <62335616+lucaseduoli@users.noreply.github.com> Date: Fri, 9 May 2025 09:30:32 -0300 Subject: [PATCH] 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 Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../components/NodeStatus/index.tsx | 42 +-- .../components/NodeUpdateComponent/index.tsx | 62 ++++ .../src/CustomNodes/GenericNode/index.tsx | 199 ++++++----- .../helpers/check-code-validity.ts | 169 +++++++++ .../hooks/use-check-code-validity.ts | 36 -- .../CustomNodes/hooks/use-update-node-code.ts | 10 +- .../core/appHeaderComponent/index.tsx | 3 - src/frontend/src/hooks/flows/use-add-flow.ts | 2 - .../src/hooks/use-reset-dismiss-update-all.ts | 14 - .../baseModal/helpers/switch-case-size.ts | 4 + src/frontend/src/modals/baseModal/index.tsx | 1 + .../src/modals/updateComponentModal/index.tsx | 225 ++++++++++++ .../components/UpdateAllComponents/index.tsx | 146 +++++--- .../components/nodeToolbarComponent/index.tsx | 9 +- src/frontend/src/pages/FlowPage/index.tsx | 10 +- .../MainPage/components/dropdown/index.tsx | 18 +- .../pages/MainPage/components/grid/index.tsx | 4 - .../pages/MainPage/components/list/index.tsx | 5 +- .../MainPage/hooks/use-handle-duplicate.ts | 31 +- src/frontend/src/stores/flowStore.ts | 66 ++-- src/frontend/src/stores/flowsManagerStore.ts | 8 - src/frontend/src/stores/utilityStore.ts | 2 - src/frontend/src/style/ag-theme-shadcn.css | 15 +- src/frontend/src/types/api/index.ts | 1 + src/frontend/src/types/components/index.ts | 2 + src/frontend/src/types/zustand/flow/index.ts | 18 +- .../src/types/zustand/flowsManager/index.ts | 2 - .../src/types/zustand/utility/index.ts | 3 - src/frontend/tests/assets/outdated_flow.json | 328 +++++++++++++----- .../features/outdated-actions.spec.ts | 144 +++++++- 30 files changed, 1160 insertions(+), 419 deletions(-) create mode 100644 src/frontend/src/CustomNodes/GenericNode/components/NodeUpdateComponent/index.tsx create mode 100644 src/frontend/src/CustomNodes/helpers/check-code-validity.ts delete mode 100644 src/frontend/src/CustomNodes/hooks/use-check-code-validity.ts delete mode 100644 src/frontend/src/hooks/use-reset-dismiss-update-all.ts create mode 100644 src/frontend/src/modals/updateComponentModal/index.tsx diff --git a/src/frontend/src/CustomNodes/GenericNode/components/NodeStatus/index.tsx b/src/frontend/src/CustomNodes/GenericNode/components/NodeStatus/index.tsx index 771c158c5..52cf3945b 100644 --- a/src/frontend/src/CustomNodes/GenericNode/components/NodeStatus/index.tsx +++ b/src/frontend/src/CustomNodes/GenericNode/components/NodeStatus/index.tsx @@ -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({ )} - {dismissAll && isOutdated && !isUserEdited && ( - -
{ - e.stopPropagation(); - handleUpdateComponent(); - e.stopPropagation(); - }} - > - {showNode && ( - - )} -
-
- )} ) : ( diff --git a/src/frontend/src/CustomNodes/GenericNode/components/NodeUpdateComponent/index.tsx b/src/frontend/src/CustomNodes/GenericNode/components/NodeUpdateComponent/index.tsx new file mode 100644 index 000000000..d0de7b823 --- /dev/null +++ b/src/frontend/src/CustomNodes/GenericNode/components/NodeUpdateComponent/index.tsx @@ -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 ( +
+
+
+ {showNode && (hasBreakingChange ? "Update available" : "Update ready")} +
+ + + +
+ ); +} diff --git a/src/frontend/src/CustomNodes/GenericNode/index.tsx b/src/frontend/src/CustomNodes/GenericNode/index.tsx index f2d14fe04..4230dd6ba 100644 --- a/src/frontend/src/CustomNodes/GenericNode/index.tsx +++ b/src/frontend/src/CustomNodes/GenericNode/index.tsx @@ -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(""); const [loadingUpdate, setLoadingUpdate] = useState(false); const [showHiddenOutputs, setShowHiddenOutputs] = useState(false); const [validationStatus, setValidationStatus] = useState(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} />
@@ -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 ( -
+
+ handleUpdateCode(true)} + components={componentUpdate ? [componentUpdate] : []} + /> {memoizedNodeToolbarComponent} - {isOutdated && !isUserEdited && !dismissAll && ( -
- - - {showNode && "Update Ready"} - - - -
+ {shouldShowUpdateComponent && ( + handleUpdateCode()} + loadingUpdate={loadingUpdate} + setDismissAll={() => addDismissedNodes([data.id])} + /> )}
{renderInputParameters()} - {shownOutputs && - shownOutputs.length > 0 && + {shownOutputs.length > 0 && renderOutputs(shownOutputs, "render-outputs")} )} diff --git a/src/frontend/src/CustomNodes/helpers/check-code-validity.ts b/src/frontend/src/CustomNodes/helpers/check-code-validity.ts new file mode 100644 index 000000000..895821505 --- /dev/null +++ b/src/frontend/src/CustomNodes/helpers/check-code-validity.ts @@ -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(); + 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; diff --git a/src/frontend/src/CustomNodes/hooks/use-check-code-validity.ts b/src/frontend/src/CustomNodes/hooks/use-check-code-validity.ts deleted file mode 100644 index e70f7e470..000000000 --- a/src/frontend/src/CustomNodes/hooks/use-check-code-validity.ts +++ /dev/null @@ -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; diff --git a/src/frontend/src/CustomNodes/hooks/use-update-node-code.ts b/src/frontend/src/CustomNodes/hooks/use-update-node-code.ts index 4ce6d80f0..2e1a2b74c 100644 --- a/src/frontend/src/CustomNodes/hooks/use-update-node-code.ts +++ b/src/frontend/src/CustomNodes/hooks/use-update-node-code.ts @@ -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; diff --git a/src/frontend/src/components/core/appHeaderComponent/index.tsx b/src/frontend/src/components/core/appHeaderComponent/index.tsx index 1c62de25d..11a4eecf2 100644 --- a/src/frontend/src/components/core/appHeaderComponent/index.tsx +++ b/src/frontend/src/components/core/appHeaderComponent/index.tsx @@ -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 diff --git a/src/frontend/src/hooks/flows/use-add-flow.ts b/src/frontend/src/hooks/flows/use-add-flow.ts index 23c229b25..81bc8bb80 100644 --- a/src/frontend/src/hooks/flows/use-add-flow.ts +++ b/src/frontend/src/hooks/flows/use-add-flow.ts @@ -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) => { diff --git a/src/frontend/src/hooks/use-reset-dismiss-update-all.ts b/src/frontend/src/hooks/use-reset-dismiss-update-all.ts deleted file mode 100644 index a49f3e773..000000000 --- a/src/frontend/src/hooks/use-reset-dismiss-update-all.ts +++ /dev/null @@ -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]); -}; diff --git a/src/frontend/src/modals/baseModal/helpers/switch-case-size.ts b/src/frontend/src/modals/baseModal/helpers/switch-case-size.ts index d2ec17ce1..b36622ea8 100644 --- a/src/frontend/src/modals/baseModal/helpers/switch-case-size.ts +++ b/src/frontend/src/modals/baseModal/helpers/switch-case-size.ts @@ -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]"; diff --git a/src/frontend/src/modals/baseModal/index.tsx b/src/frontend/src/modals/baseModal/index.tsx index 9db3f4fcc..ecd4ba6f1 100644 --- a/src/frontend/src/modals/baseModal/index.tsx +++ b/src/frontend/src/modals/baseModal/index.tsx @@ -172,6 +172,7 @@ interface BaseModalProps { | "retangular" | "smaller" | "small" + | "small-update" | "small-query" | "medium" | "medium-tall" diff --git a/src/frontend/src/modals/updateComponentModal/index.tsx b/src/frontend/src/modals/updateComponentModal/index.tsx new file mode 100644 index 000000000..f297e8e36 --- /dev/null +++ b/src/frontend/src/modals/updateComponentModal/index.tsx @@ -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(true); + const [loading, setLoading] = useState(false); + const [selectedComponents, setSelectedComponents] = useState>( + new Set(components.filter((c) => !c.breakingChange).map((c) => c.id)), + ); + const agGrid = useRef(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 ( +
+ {params.data.icon && ( + + )} + {params.value} +
+ ); + }, + }, + { + headerName: "Update Type", + field: "breakingChange", + headerClass: "!text-mmd !font-normal", + resizable: false, + flex: 1, + cellClass: "text-muted-foreground", + cellRenderer: (params) => { + return params.value ? ( + + Breaking + + ) : ( + Standard + ); + }, + }, + ]; + + 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 ( + + {children ?? <>} + + + Update{" "} + {isMultiple ? "components" : (components?.[0]?.display_name ?? "")} + + + +
+
+ {isMultiple ? ( +

+ Updates marked as{" "} + + breaking + {" "} + 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. +

+ ) : ( + <> +

+ This update may change inputs, outputs, or component behavior. + In some cases, it will{" "} + + disconnect this component from your flow + + , requiring you to review or reconnect it afterward. +

+

+ Components added from the sidebar always use the latest + version. +

+ + )} +
+ {isMultiple && ( +
+ { + const selectedIds = event.api + .getSelectedRows() + .map((row) => row.id); + setSelectedComponents(new Set(selectedIds)); + }} + suppressRowHoverHighlight={true} + tableOptions={{ hide_options: true }} + /> +
+ )} +
+ + setBackupFlow(checked === "indeterminate" ? false : checked) + } + className="bg-muted" + id="backupFlow" + data-testid="backup-flow-checkbox" + /> + +
+
+
+ 1 ? "s" : ""), + onClick: handleUpdate, + disabled: isMultiple && selectedComponents.size === 0, + loading, + }} + > +
+ ); +} diff --git a/src/frontend/src/pages/FlowPage/components/UpdateAllComponents/index.tsx b/src/frontend/src/pages/FlowPage/components/UpdateAllComponents/index.tsx index 2055cfa05..29c447ce0 100644 --- a/src/frontend/src/pages/FlowPage/components/UpdateAllComponents/index.tsx +++ b/src/frontend/src/pages/FlowPage/components/UpdateAllComponents/index.tsx @@ -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 (
component.breakingChange, + ) && "border-accent-amber-foreground", )} >
- - {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" : "")}
+ handleUpdateAllComponents(true, ids)} + components={componentsToUpdateFiltered} + />
); } diff --git a/src/frontend/src/pages/FlowPage/components/nodeToolbarComponent/index.tsx b/src/frontend/src/pages/FlowPage/components/nodeToolbarComponent/index.tsx index 62133fbd1..125c3dd2b 100644 --- a/src/frontend/src/pages/FlowPage/components/nodeToolbarComponent/index.tsx +++ b/src/frontend/src/pages/FlowPage/components/nodeToolbarComponent/index.tsx @@ -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" /> diff --git a/src/frontend/src/pages/FlowPage/index.tsx b/src/frontend/src/pages/FlowPage/index.tsx index 96a1258b9..4062f4c25 100644 --- a/src/frontend/src/pages/FlowPage/index.tsx +++ b/src/frontend/src/pages/FlowPage/index.tsx @@ -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); }; diff --git a/src/frontend/src/pages/MainPage/components/dropdown/index.tsx b/src/frontend/src/pages/MainPage/components/dropdown/index.tsx index 1b1144c43..d76b16f8e 100644 --- a/src/frontend/src/pages/MainPage/components/dropdown/index.tsx +++ b/src/frontend/src/pages/MainPage/components/dropdown/index.tsx @@ -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, ); diff --git a/src/frontend/src/pages/MainPage/components/grid/index.tsx b/src/frontend/src/pages/MainPage/components/grid/index.tsx index d38ce85aa..405ac1e07 100644 --- a/src/frontend/src/pages/MainPage/components/grid/index.tsx +++ b/src/frontend/src/pages/MainPage/components/grid/index.tsx @@ -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); } }; diff --git a/src/frontend/src/pages/MainPage/components/list/index.tsx b/src/frontend/src/pages/MainPage/components/list/index.tsx index 80ffe17e2..9819817d8 100644 --- a/src/frontend/src/pages/MainPage/components/list/index.tsx +++ b/src/frontend/src/pages/MainPage/components/list/index.tsx @@ -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); } }; diff --git a/src/frontend/src/pages/MainPage/hooks/use-handle-duplicate.ts b/src/frontend/src/pages/MainPage/hooks/use-handle-duplicate.ts index 5de6ebc18..f1502b7cd 100644 --- a/src/frontend/src/pages/MainPage/hooks/use-handle-duplicate.ts +++ b/src/frontend/src/pages/MainPage/hooks/use-handle-duplicate.ts @@ -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; diff --git a/src/frontend/src/stores/flowStore.ts b/src/frontend/src/stores/flowStore.ts index b4be016b0..0cdb64491 100644 --- a/src/frontend/src/stores/flowStore.ts +++ b/src/frontend/src/stores/flowStore.ts @@ -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((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((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((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; diff --git a/src/frontend/src/stores/flowsManagerStore.ts b/src/frontend/src/stores/flowsManagerStore.ts index 4c2c1f54e..4336080c3 100644 --- a/src/frontend/src/stores/flowsManagerStore.ts +++ b/src/frontend/src/stores/flowsManagerStore.ts @@ -130,19 +130,11 @@ const useFlowsManagerStore = create((set, get) => ({ setSelectedFlowsComponentsCards: (selectedFlowsComponentsCards: string[]) => { set({ selectedFlowsComponentsCards }); }, - flowToCanvas: null, - setFlowToCanvas: async (flowToCanvas: FlowType | null) => { - await new Promise((resolve) => { - set({ flowToCanvas }); - resolve(); - }); - }, resetStore: () => { set({ flows: [], currentFlow: undefined, currentFlowId: "", - flowToCanvas: null, searchFlowsComponents: "", selectedFlowsComponentsCards: [], }); diff --git a/src/frontend/src/stores/utilityStore.ts b/src/frontend/src/stores/utilityStore.ts index 43a0ee8d0..c5a472e09 100644 --- a/src/frontend/src/stores/utilityStore.ts +++ b/src/frontend/src/stores/utilityStore.ts @@ -6,8 +6,6 @@ import { create } from "zustand"; export const useUtilityStore = create((set, get) => ({ clientId: "", setClientId: (clientId: string) => set({ clientId }), - dismissAll: false, - setDismissAll: (dismissAll: boolean) => set({ dismissAll }), chatValueStore: "", setChatValueStore: (value: string) => set({ chatValueStore: value }), selectedItems: [], diff --git a/src/frontend/src/style/ag-theme-shadcn.css b/src/frontend/src/style/ag-theme-shadcn.css index a538f6cb4..ee9496356 100644 --- a/src/frontend/src/style/ag-theme-shadcn.css +++ b/src/frontend/src/style/ag-theme-shadcn.css @@ -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; +} diff --git a/src/frontend/src/types/api/index.ts b/src/frontend/src/types/api/index.ts index 8ed40e2c1..d228d811e 100644 --- a/src/frontend/src/types/api/index.ts +++ b/src/frontend/src/types/api/index.ts @@ -104,6 +104,7 @@ export type OutputFieldType = { types: Array; selected?: string; name: string; + method?: string; display_name: string; hidden?: boolean; proxy?: OutputFieldProxyType; diff --git a/src/frontend/src/types/components/index.ts b/src/frontend/src/types/components/index.ts index e0a07f789..d765b8c13 100644 --- a/src/frontend/src/types/components/index.ts +++ b/src/frontend/src/types/components/index.ts @@ -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; diff --git a/src/frontend/src/types/zustand/flow/index.ts b/src/frontend/src/types/zustand/flow/index.ts index 5c757fc38..8db8246fc 100644 --- a/src/frontend/src/types/zustand/flow/index.ts +++ b/src/frontend/src/types/zustand/flow/index.ts @@ -52,7 +52,19 @@ export type FlowPoolType = { [key: string]: Array; }; +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; diff --git a/src/frontend/src/types/zustand/flowsManager/index.ts b/src/frontend/src/types/zustand/flowsManager/index.ts index 1fb85c0ff..5a15565f4 100644 --- a/src/frontend/src/types/zustand/flowsManager/index.ts +++ b/src/frontend/src/types/zustand/flowsManager/index.ts @@ -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; IOModalOpen: boolean; setIOModalOpen: (IOModalOpen: boolean) => void; resetStore: () => void; diff --git a/src/frontend/src/types/zustand/utility/index.ts b/src/frontend/src/types/zustand/utility/index.ts index 4244dbbfe..8bebaf8f5 100644 --- a/src/frontend/src/types/zustand/utility/index.ts +++ b/src/frontend/src/types/zustand/utility/index.ts @@ -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; diff --git a/src/frontend/tests/assets/outdated_flow.json b/src/frontend/tests/assets/outdated_flow.json index 66ca450da..8c2acb884 100644 --- a/src/frontend/tests/assets/outdated_flow.json +++ b/src/frontend/tests/assets/outdated_flow.json @@ -1,94 +1,101 @@ { - "id": "cb35184a-3446-4074-9ec7-8a935e980114", "data": { "edges": [ { + "animated": false, "className": "", "data": { "sourceHandle": { "dataType": "ChatInput", - "id": "ChatInput-KovKB", + "id": "ChatInput-BaSJ6", "name": "message", "output_types": ["Message"] }, "targetHandle": { "fieldName": "user_message", - "id": "Prompt-Xz9bN", + "id": "Prompt-6mzLk", "inputTypes": ["Message", "Text"], "type": "str" } }, - "id": "reactflow__edge-ChatInput-KovKB{œdataTypeœ:œChatInputœ,œidœ:œChatInput-KovKBœ,œnameœ:œmessageœ,œoutput_typesœ:[œMessageœ]}-Prompt-Xz9bN{œfieldNameœ:œuser_messageœ,œidœ:œPrompt-Xz9bNœ,œinputTypesœ:[œMessageœ,œTextœ],œtypeœ:œstrœ}", - "source": "ChatInput-KovKB", - "sourceHandle": "{œdataTypeœ:œChatInputœ,œidœ:œChatInput-KovKBœ,œnameœ:œmessageœ,œoutput_typesœ:[œMessageœ]}", - "target": "Prompt-Xz9bN", - "targetHandle": "{œfieldNameœ:œuser_messageœ,œidœ:œPrompt-Xz9bNœ,œinputTypesœ:[œMessageœ,œTextœ],œtypeœ:œstrœ}" + "id": "reactflow__edge-ChatInput-BaSJ6{œdataTypeœ:œChatInputœ,œidœ:œChatInput-BaSJ6œ,œnameœ:œmessageœ,œoutput_typesœ:[œMessageœ]}-Prompt-6mzLk{œfieldNameœ:œuser_messageœ,œidœ:œPrompt-6mzLkœ,œinputTypesœ:[œMessageœ,œTextœ],œtypeœ:œstrœ}", + "selected": false, + "source": "ChatInput-BaSJ6", + "sourceHandle": "{œdataTypeœ:œChatInputœ,œidœ:œChatInput-BaSJ6œ,œnameœ:œmessageœ,œoutput_typesœ:[œMessageœ]}", + "target": "Prompt-6mzLk", + "targetHandle": "{œfieldNameœ:œuser_messageœ,œidœ:œPrompt-6mzLkœ,œinputTypesœ:[œMessageœ,œTextœ],œtypeœ:œstrœ}" }, { + "animated": false, "className": "", "data": { "sourceHandle": { "dataType": "Prompt", - "id": "Prompt-Xz9bN", + "id": "Prompt-6mzLk", "name": "prompt", "output_types": ["Message"] }, "targetHandle": { "fieldName": "input_value", - "id": "OpenAIModel-pqHDB", + "id": "OpenAIModel-gQ2Hu", "inputTypes": ["Message"], "type": "str" } }, - "id": "reactflow__edge-Prompt-Xz9bN{œdataTypeœ:œPromptœ,œidœ:œPrompt-Xz9bNœ,œnameœ:œpromptœ,œoutput_typesœ:[œMessageœ]}-OpenAIModel-pqHDB{œfieldNameœ:œinput_valueœ,œidœ:œOpenAIModel-pqHDBœ,œinputTypesœ:[œMessageœ],œtypeœ:œstrœ}", - "source": "Prompt-Xz9bN", - "sourceHandle": "{œdataTypeœ:œPromptœ,œidœ:œPrompt-Xz9bNœ,œnameœ:œpromptœ,œoutput_typesœ:[œMessageœ]}", - "target": "OpenAIModel-pqHDB", - "targetHandle": "{œfieldNameœ:œinput_valueœ,œidœ:œOpenAIModel-pqHDBœ,œinputTypesœ:[œMessageœ],œtypeœ:œstrœ}" + "id": "reactflow__edge-Prompt-6mzLk{œdataTypeœ:œPromptœ,œidœ:œPrompt-6mzLkœ,œnameœ:œpromptœ,œoutput_typesœ:[œMessageœ]}-OpenAIModel-gQ2Hu{œfieldNameœ:œinput_valueœ,œidœ:œOpenAIModel-gQ2Huœ,œinputTypesœ:[œMessageœ],œtypeœ:œstrœ}", + "selected": false, + "source": "Prompt-6mzLk", + "sourceHandle": "{œdataTypeœ:œPromptœ,œidœ:œPrompt-6mzLkœ,œnameœ:œpromptœ,œoutput_typesœ:[œMessageœ]}", + "target": "OpenAIModel-gQ2Hu", + "targetHandle": "{œfieldNameœ:œinput_valueœ,œidœ:œOpenAIModel-gQ2Huœ,œinputTypesœ:[œMessageœ],œtypeœ:œstrœ}" }, { + "animated": false, "className": "", "data": { "sourceHandle": { "dataType": "OpenAIModel", - "id": "OpenAIModel-pqHDB", + "id": "OpenAIModel-gQ2Hu", "name": "text_output", "output_types": ["Message"] }, "targetHandle": { "fieldName": "input_value", - "id": "ChatOutput-NasE4", + "id": "ChatOutput-64KvA", "inputTypes": ["Message"], "type": "str" } }, - "id": "reactflow__edge-OpenAIModel-pqHDB{œdataTypeœ:œOpenAIModelœ,œidœ:œOpenAIModel-pqHDBœ,œnameœ:œtext_outputœ,œoutput_typesœ:[œMessageœ]}-ChatOutput-NasE4{œfieldNameœ:œinput_valueœ,œidœ:œChatOutput-NasE4œ,œinputTypesœ:[œMessageœ],œtypeœ:œstrœ}", - "source": "OpenAIModel-pqHDB", - "sourceHandle": "{œdataTypeœ:œOpenAIModelœ,œidœ:œOpenAIModel-pqHDBœ,œnameœ:œtext_outputœ,œoutput_typesœ:[œMessageœ]}", - "target": "ChatOutput-NasE4", - "targetHandle": "{œfieldNameœ:œinput_valueœ,œidœ:œChatOutput-NasE4œ,œinputTypesœ:[œMessageœ],œtypeœ:œstrœ}" + "id": "reactflow__edge-OpenAIModel-gQ2Hu{œdataTypeœ:œOpenAIModelœ,œidœ:œOpenAIModel-gQ2Huœ,œnameœ:œtext_outputœ,œoutput_typesœ:[œMessageœ]}-ChatOutput-64KvA{œfieldNameœ:œinput_valueœ,œidœ:œChatOutput-64KvAœ,œinputTypesœ:[œMessageœ],œtypeœ:œstrœ}", + "selected": false, + "source": "OpenAIModel-gQ2Hu", + "sourceHandle": "{œdataTypeœ:œOpenAIModelœ,œidœ:œOpenAIModel-gQ2Huœ,œnameœ:œtext_outputœ,œoutput_typesœ:[œMessageœ]}", + "target": "ChatOutput-64KvA", + "targetHandle": "{œfieldNameœ:œinput_valueœ,œidœ:œChatOutput-64KvAœ,œinputTypesœ:[œMessageœ],œtypeœ:œstrœ}" }, { + "animated": false, "className": "", "data": { "sourceHandle": { "dataType": "Memory", - "id": "Memory-x4ENQ", + "id": "Memory-hrM7A", "name": "messages_text", "output_types": ["Message"] }, "targetHandle": { "fieldName": "context", - "id": "Prompt-Xz9bN", + "id": "Prompt-6mzLk", "inputTypes": ["Message", "Text"], "type": "str" } }, - "id": "reactflow__edge-Memory-x4ENQ{œdataTypeœ:œMemoryœ,œidœ:œMemory-x4ENQœ,œnameœ:œmessages_textœ,œoutput_typesœ:[œMessageœ]}-Prompt-Xz9bN{œfieldNameœ:œcontextœ,œidœ:œPrompt-Xz9bNœ,œinputTypesœ:[œMessageœ,œTextœ],œtypeœ:œstrœ}", - "source": "Memory-x4ENQ", - "sourceHandle": "{œdataTypeœ:œMemoryœ,œidœ:œMemory-x4ENQœ,œnameœ:œmessages_textœ,œoutput_typesœ:[œMessageœ]}", - "target": "Prompt-Xz9bN", - "targetHandle": "{œfieldNameœ:œcontextœ,œidœ:œPrompt-Xz9bNœ,œinputTypesœ:[œMessageœ,œTextœ],œtypeœ:œstrœ}" + "id": "reactflow__edge-Memory-hrM7A{œdataTypeœ:œMemoryœ,œidœ:œMemory-hrM7Aœ,œnameœ:œmessages_textœ,œoutput_typesœ:[œMessageœ]}-Prompt-6mzLk{œfieldNameœ:œcontextœ,œidœ:œPrompt-6mzLkœ,œinputTypesœ:[œMessageœ,œTextœ],œtypeœ:œstrœ}", + "selected": false, + "source": "Memory-hrM7A", + "sourceHandle": "{œdataTypeœ:œMemoryœ,œidœ:œMemory-hrM7Aœ,œnameœ:œmessages_textœ,œoutput_typesœ:[œMessageœ]}", + "target": "Prompt-6mzLk", + "targetHandle": "{œfieldNameœ:œcontextœ,œidœ:œPrompt-6mzLkœ,œinputTypesœ:[œMessageœ,œTextœ],œtypeœ:œstrœ}" } ], "nodes": [ @@ -96,12 +103,14 @@ "data": { "description": "Create a prompt template with dynamic variables.", "display_name": "Prompt", - "id": "Prompt-Xz9bN", + "id": "Prompt-6mzLk", "node": { "base_classes": ["Message"], "beta": false, "conditional_paths": [], - "custom_fields": { "template": ["context", "user_message"] }, + "custom_fields": { + "template": ["context", "user_message"] + }, "description": "Create a prompt template with dynamic variables.", "display_name": "Prompt", "documentation": "", @@ -114,6 +123,7 @@ { "cache": true, "display_name": "Prompt Message", + "hidden": false, "method": "build_prompt", "name": "prompt", "selected": "Message", @@ -206,9 +216,19 @@ }, "dragging": false, "height": 517, - "id": "Prompt-Xz9bN", - "position": { "x": 1880.8227904110583, "y": 625.8049209882275 }, - "positionAbsolute": { "x": 1880.8227904110583, "y": 625.8049209882275 }, + "id": "Prompt-6mzLk", + "measured": { + "height": 517, + "width": 320 + }, + "position": { + "x": 1880.8227904110583, + "y": 625.8049209882275 + }, + "positionAbsolute": { + "x": 1880.8227904110583, + "y": 625.8049209882275 + }, "selected": false, "type": "genericNode", "width": 384 @@ -217,7 +237,7 @@ "data": { "description": "Get chat inputs from the Playground.", "display_name": "Chat Input", - "id": "ChatInput-KovKB", + "id": "ChatInput-BaSJ6", "node": { "base_classes": ["Message"], "beta": false, @@ -242,6 +262,7 @@ { "cache": true, "display_name": "Message", + "hidden": false, "method": "message_response", "name": "message", "selected": "Message", @@ -403,9 +424,19 @@ }, "dragging": false, "height": 309, - "id": "ChatInput-KovKB", - "position": { "x": 1275.9262193671882, "y": 836.1228056896347 }, - "positionAbsolute": { "x": 1275.9262193671882, "y": 836.1228056896347 }, + "id": "ChatInput-BaSJ6", + "measured": { + "height": 309, + "width": 320 + }, + "position": { + "x": 1275.9262193671882, + "y": 836.1228056896347 + }, + "positionAbsolute": { + "x": 1275.9262193671882, + "y": 836.1228056896347 + }, "selected": false, "type": "genericNode", "width": 384 @@ -414,7 +445,7 @@ "data": { "description": "Generates text using OpenAI LLMs.", "display_name": "OpenAI", - "id": "OpenAIModel-pqHDB", + "id": "OpenAIModel-gQ2Hu", "node": { "base_classes": ["LanguageModel", "Message"], "beta": false, @@ -426,37 +457,50 @@ "edited": false, "field_order": [ "input_value", + "system_message", + "stream", "max_tokens", "model_kwargs", "json_mode", - "output_schema", "model_name", "openai_api_base", - "openai_api_key", + "api_key", "temperature", - "stream", - "system_message", - "seed" + "seed", + "max_retries", + "timeout" ], "frozen": false, "icon": "OpenAI", + "legacy": false, + "metadata": {}, + "minimized": false, "output_types": [], "outputs": [ { + "allows_loop": false, "cache": true, - "display_name": "Text", + "display_name": "Message", + "hidden": false, "method": "text_response", "name": "text_output", + "options": null, + "required_inputs": [], "selected": "Message", + "tool_mode": true, "types": ["Message"], "value": "__UNDEFINED__" }, { + "allows_loop": false, "cache": true, "display_name": "Language Model", "method": "build_model", "name": "model_output", + "options": null, + "required_inputs": ["api_key"], "selected": "LanguageModel", + "tool_mode": true, "types": ["LanguageModel"], "value": "__UNDEFINED__" } @@ -470,12 +514,12 @@ "display_name": "OpenAI API Key", "dynamic": false, "info": "The OpenAI API Key to use for the OpenAI model.", - "input_types": ["Message"], - "load_from_db": true, + "input_types": [], + "load_from_db": false, "name": "api_key", "password": true, "placeholder": "", - "required": false, + "required": true, "show": true, "title_case": false, "type": "str", @@ -497,73 +541,112 @@ "show": true, "title_case": false, "type": "code", - "value": "import operator\nfrom functools import reduce\n\nfrom langflow.field_typing.range_spec import RangeSpec\nfrom langchain_openai import ChatOpenAI\nfrom pydantic.v1 import SecretStr\n\nfrom langflow.base.models.model import LCModelComponent\nfrom langflow.base.models.openai_constants import OPENAI_MODEL_NAMES\nfrom langflow.field_typing import LanguageModel\nfrom langflow.inputs import (\n BoolInput,\n DictInput,\n DropdownInput,\n FloatInput,\n IntInput,\n SecretStrInput,\n StrInput,\n)\n\n\nclass OpenAIModelComponent(LCModelComponent):\n display_name = \"OpenAI\"\n description = \"Generates text using OpenAI LLMs.\"\n icon = \"OpenAI\"\n name = \"OpenAIModel\"\n\n inputs = LCModelComponent._base_inputs + [\n IntInput(\n name=\"max_tokens\",\n display_name=\"Max Tokens\",\n advanced=True,\n info=\"The maximum number of tokens to generate. Set to 0 for unlimited tokens.\",\n range_spec=RangeSpec(min=0, max=128000),\n ),\n DictInput(name=\"model_kwargs\", display_name=\"Model Kwargs\", advanced=True),\n BoolInput(\n name=\"json_mode\",\n display_name=\"JSON Mode\",\n advanced=True,\n info=\"If True, it will output JSON regardless of passing a schema.\",\n ),\n DictInput(\n name=\"output_schema\",\n is_list=True,\n display_name=\"Schema\",\n advanced=True,\n info=\"The schema for the Output of the model. You must pass the word JSON in the prompt. If left blank, JSON mode will be disabled.\",\n ),\n DropdownInput(\n name=\"model_name\",\n display_name=\"Model Name\",\n advanced=False,\n options=OPENAI_MODEL_NAMES,\n value=OPENAI_MODEL_NAMES[0],\n ),\n StrInput(\n name=\"openai_api_base\",\n display_name=\"OpenAI API Base\",\n advanced=True,\n info=\"The base URL of the OpenAI API. Defaults to https://api.openai.com/v1. You can change this to use other APIs like JinaChat, LocalAI and Prem.\",\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"OpenAI API Key\",\n info=\"The OpenAI API Key to use for the OpenAI model.\",\n advanced=False,\n value=\"OPENAI_API_KEY\",\n ),\n FloatInput(name=\"temperature\", display_name=\"Temperature\", value=0.1),\n IntInput(\n name=\"seed\",\n display_name=\"Seed\",\n info=\"The seed controls the reproducibility of the job.\",\n advanced=True,\n value=1,\n ),\n ]\n\n def build_model(self) -> LanguageModel: # type: ignore[type-var]\n # self.output_schema is a list of dictionaries\n # let's convert it to a dictionary\n output_schema_dict: dict[str, str] = reduce(operator.ior, self.output_schema or {}, {})\n openai_api_key = self.api_key\n temperature = self.temperature\n model_name: str = self.model_name\n max_tokens = self.max_tokens\n model_kwargs = self.model_kwargs or {}\n openai_api_base = self.openai_api_base or \"https://api.openai.com/v1\"\n json_mode = bool(output_schema_dict) or self.json_mode\n seed = self.seed\n\n if openai_api_key:\n api_key = SecretStr(openai_api_key)\n else:\n api_key = None\n output = ChatOpenAI(\n max_tokens=max_tokens or None,\n model_kwargs=model_kwargs,\n model=model_name,\n base_url=openai_api_base,\n api_key=api_key,\n temperature=temperature or 0.1,\n seed=seed,\n )\n if json_mode:\n if output_schema_dict:\n output = output.with_structured_output(schema=output_schema_dict, method=\"json_mode\") # type: ignore\n else:\n output = output.bind(response_format={\"type\": \"json_object\"}) # type: ignore\n\n return output # type: ignore\n\n def _get_exception_message(self, e: Exception):\n \"\"\"\n Get a message from an OpenAI exception.\n\n Args:\n exception (Exception): The exception to get the message from.\n\n Returns:\n str: The message from the exception.\n \"\"\"\n\n try:\n from openai import BadRequestError\n except ImportError:\n return\n if isinstance(e, BadRequestError):\n message = e.body.get(\"message\") # type: ignore\n if message:\n return message\n return\n" + "value": "from typing import Any\n\nfrom langchain_openai import ChatOpenAI\nfrom pydantic.v1 import SecretStr\n\nfrom langflow.base.models.model import LCModelComponent\nfrom langflow.base.models.openai_constants import (\n OPENAI_MODEL_NAMES,\n OPENAI_REASONING_MODEL_NAMES,\n)\nfrom langflow.field_typing import LanguageModel\nfrom langflow.field_typing.range_spec import RangeSpec\nfrom langflow.inputs import BoolInput, DictInput, DropdownInput, IntInput, SecretStrInput, SliderInput, StrInput\nfrom langflow.logging import logger\n\n\nclass OpenAIModelComponent(LCModelComponent):\n display_name = \"OpenAI\"\n description = \"Generates text using OpenAI LLMs.\"\n icon = \"OpenAI\"\n name = \"OpenAIModel\"\n\n inputs = [\n *LCModelComponent._base_inputs,\n IntInput(\n name=\"max_tokens\",\n display_name=\"Max Tokens\",\n advanced=True,\n info=\"The maximum number of tokens to generate. Set to 0 for unlimited tokens.\",\n range_spec=RangeSpec(min=0, max=128000),\n ),\n DictInput(\n name=\"model_kwargs\",\n display_name=\"Model Kwargs\",\n advanced=True,\n info=\"Additional test keyword arguments to pass to the model.\",\n ),\n BoolInput(\n name=\"json_mode\",\n display_name=\"JSON Mode\",\n advanced=True,\n info=\"If True, it will output JSON regardless of passing a schema.\",\n ),\n DropdownInput(\n name=\"model_name\",\n display_name=\"Model Name\",\n advanced=False,\n options=OPENAI_MODEL_NAMES + OPENAI_REASONING_MODEL_NAMES,\n value=OPENAI_MODEL_NAMES[1],\n combobox=True,\n real_time_refresh=True,\n ),\n StrInput(\n name=\"openai_api_base\",\n display_name=\"OpenAI API Base\",\n advanced=True,\n info=\"The base URL of the OpenAI API. \"\n \"Defaults to https://api.openai.com/v1. \"\n \"You can change this to use other APIs like JinaChat, LocalAI and Prem.\",\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"OpenAI API Key\",\n info=\"The OpenAI API Key to use for the OpenAI model.\",\n advanced=False,\n value=\"OPENAI_API_KEY\",\n required=True,\n ),\n SliderInput(\n name=\"temperature\",\n display_name=\"Temperature\",\n value=0.1,\n range_spec=RangeSpec(min=0, max=1, step=0.01),\n show=True,\n ),\n IntInput(\n name=\"seed\",\n display_name=\"Seed\",\n info=\"The seed controls the reproducibility of the job.\",\n advanced=True,\n value=1,\n ),\n IntInput(\n name=\"max_retries\",\n display_name=\"Max Retries\",\n info=\"The maximum number of retries to make when generating.\",\n advanced=True,\n value=5,\n ),\n IntInput(\n name=\"timeout\",\n display_name=\"Timeout\",\n info=\"The timeout for requests to OpenAI completion API.\",\n advanced=True,\n value=700,\n ),\n ]\n\n def build_model(self) -> LanguageModel: # type: ignore[type-var]\n parameters = {\n \"api_key\": SecretStr(self.api_key).get_secret_value() if self.api_key else None,\n \"model_name\": self.model_name,\n \"max_tokens\": self.max_tokens or None,\n \"model_kwargs\": self.model_kwargs or {},\n \"base_url\": self.openai_api_base or \"https://api.openai.com/v1\",\n \"seed\": self.seed,\n \"max_retries\": self.max_retries,\n \"timeout\": self.timeout,\n \"temperature\": self.temperature if self.temperature is not None else 0.1,\n }\n\n logger.info(f\"Model name: {self.model_name}\")\n if self.model_name in OPENAI_REASONING_MODEL_NAMES:\n logger.info(\"Getting reasoning model parameters\")\n parameters.pop(\"temperature\")\n parameters.pop(\"seed\")\n output = ChatOpenAI(**parameters)\n if self.json_mode:\n output = output.bind(response_format={\"type\": \"json_object\"})\n\n return output\n\n def _get_exception_message(self, e: Exception):\n \"\"\"Get a message from an OpenAI exception.\n\n Args:\n e (Exception): The exception to get the message from.\n\n Returns:\n str: The message from the exception.\n \"\"\"\n try:\n from openai import BadRequestError\n except ImportError:\n return None\n if isinstance(e, BadRequestError):\n message = e.body.get(\"message\")\n if message:\n return message\n return None\n\n def update_build_config(self, build_config: dict, field_value: Any, field_name: str | None = None) -> dict:\n if field_name in {\"base_url\", \"model_name\", \"api_key\"} and field_value in OPENAI_REASONING_MODEL_NAMES:\n build_config[\"temperature\"][\"show\"] = False\n build_config[\"seed\"][\"show\"] = False\n if field_name in {\"base_url\", \"model_name\", \"api_key\"} and field_value in OPENAI_MODEL_NAMES:\n build_config[\"temperature\"][\"show\"] = True\n build_config[\"seed\"][\"show\"] = True\n return build_config\n" }, "input_value": { + "_input_type": "MessageInput", "advanced": false, "display_name": "Input", "dynamic": false, "info": "", "input_types": ["Message"], "list": false, + "list_add_label": "Add More", "load_from_db": false, "name": "input_value", "placeholder": "", "required": false, "show": true, "title_case": false, + "tool_mode": false, "trace_as_input": true, "trace_as_metadata": true, "type": "str", "value": "" }, "json_mode": { + "_input_type": "BoolInput", "advanced": true, "display_name": "JSON Mode", "dynamic": false, "info": "If True, it will output JSON regardless of passing a schema.", "list": false, + "list_add_label": "Add More", "name": "json_mode", "placeholder": "", "required": false, "show": true, "title_case": false, + "tool_mode": false, "trace_as_metadata": true, "type": "bool", "value": false }, + "max_retries": { + "_input_type": "IntInput", + "advanced": true, + "display_name": "Max Retries", + "dynamic": false, + "info": "The maximum number of retries to make when generating.", + "list": false, + "list_add_label": "Add More", + "name": "max_retries", + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "type": "int", + "value": 5 + }, "max_tokens": { + "_input_type": "IntInput", "advanced": true, "display_name": "Max Tokens", "dynamic": false, "info": "The maximum number of tokens to generate. Set to 0 for unlimited tokens.", "list": false, + "list_add_label": "Add More", "name": "max_tokens", "placeholder": "", + "range_spec": { + "max": 128000, + "min": 0, + "step": 0.1, + "step_type": "float" + }, "required": false, "show": true, "title_case": false, + "tool_mode": false, "trace_as_metadata": true, "type": "int", "value": "" }, "model_kwargs": { + "_input_type": "DictInput", "advanced": true, "display_name": "Model Kwargs", "dynamic": false, - "info": "", + "info": "Additional test keyword arguments to pass to the model.", "list": false, + "list_add_label": "Add More", "name": "model_kwargs", "placeholder": "", "required": false, "show": true, "title_case": false, + "tool_mode": false, "trace_as_input": true, "type": "dict", "value": {} }, "model_name": { + "_input_type": "DropdownInput", "advanced": false, + "combobox": true, + "dialog_inputs": {}, "display_name": "Model Name", "dynamic": false, "info": "", @@ -571,121 +654,172 @@ "options": [ "gpt-4o-mini", "gpt-4o", + "gpt-4.1", + "gpt-4.1-mini", + "gpt-4.1-nano", + "gpt-4.5-preview", "gpt-4-turbo", "gpt-4-turbo-preview", "gpt-4", "gpt-3.5-turbo", - "gpt-3.5-turbo-0125" + "o1" ], + "options_metadata": [], "placeholder": "", + "real_time_refresh": true, "required": false, "show": true, "title_case": false, + "toggle": false, + "tool_mode": false, "trace_as_metadata": true, "type": "str", "value": "gpt-4o" }, "openai_api_base": { + "_input_type": "StrInput", "advanced": true, "display_name": "OpenAI API Base", "dynamic": false, "info": "The base URL of the OpenAI API. Defaults to https://api.openai.com/v1. You can change this to use other APIs like JinaChat, LocalAI and Prem.", "list": false, + "list_add_label": "Add More", "load_from_db": false, "name": "openai_api_base", "placeholder": "", "required": false, "show": true, "title_case": false, + "tool_mode": false, "trace_as_metadata": true, "type": "str", "value": "" }, - "output_schema": { - "advanced": true, - "display_name": "Schema", - "dynamic": false, - "info": "The schema for the Output of the model. You must pass the word JSON in the prompt. If left blank, JSON mode will be disabled.", - "list": true, - "name": "output_schema", - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "trace_as_input": true, - "type": "dict", - "value": {} - }, "seed": { + "_input_type": "IntInput", "advanced": true, "display_name": "Seed", "dynamic": false, "info": "The seed controls the reproducibility of the job.", "list": false, + "list_add_label": "Add More", "name": "seed", "placeholder": "", "required": false, "show": true, "title_case": false, + "tool_mode": false, "trace_as_metadata": true, "type": "int", "value": 1 }, "stream": { + "_input_type": "BoolInput", "advanced": true, "display_name": "Stream", "dynamic": false, "info": "Stream the response from the model. Streaming works only in Chat.", "list": false, + "list_add_label": "Add More", "name": "stream", "placeholder": "", "required": false, "show": true, "title_case": false, + "tool_mode": false, "trace_as_metadata": true, "type": "bool", "value": false }, "system_message": { - "advanced": true, + "_input_type": "MultilineInput", + "advanced": false, + "copy_field": false, "display_name": "System Message", "dynamic": false, "info": "System message to pass to the model.", + "input_types": ["Message"], "list": false, + "list_add_label": "Add More", "load_from_db": false, + "multiline": true, "name": "system_message", "placeholder": "", "required": false, "show": true, "title_case": false, + "tool_mode": false, + "trace_as_input": true, "trace_as_metadata": true, "type": "str", "value": "" }, "temperature": { + "_input_type": "SliderInput", "advanced": false, "display_name": "Temperature", "dynamic": false, "info": "", - "list": false, + "max_label": "", + "max_label_icon": "", + "min_label": "", + "min_label_icon": "", "name": "temperature", "placeholder": "", + "range_spec": { + "max": 1, + "min": 0, + "step": 0.01, + "step_type": "float" + }, + "required": false, + "show": true, + "slider_buttons": false, + "slider_buttons_options": [], + "slider_input": false, + "title_case": false, + "tool_mode": false, + "type": "slider", + "value": 0.1 + }, + "timeout": { + "_input_type": "IntInput", + "advanced": true, + "display_name": "Timeout", + "dynamic": false, + "info": "The timeout for requests to OpenAI completion API.", + "list": false, + "list_add_label": "Add More", + "name": "timeout", + "placeholder": "", "required": false, "show": true, "title_case": false, + "tool_mode": false, "trace_as_metadata": true, - "type": "float", - "value": 0.1 + "type": "int", + "value": 700 } - } + }, + "tool_mode": false }, "type": "OpenAIModel" }, "dragging": false, "height": 623, - "id": "OpenAIModel-pqHDB", - "position": { "x": 2468.968379487559, "y": 560.0689522326683 }, - "positionAbsolute": { "x": 2468.968379487559, "y": 560.0689522326683 }, + "id": "OpenAIModel-gQ2Hu", + "measured": { + "height": 623, + "width": 320 + }, + "position": { + "x": 2468.968379487559, + "y": 560.0689522326683 + }, + "positionAbsolute": { + "x": 2468.968379487559, + "y": 560.0689522326683 + }, "selected": false, "type": "genericNode", "width": 384 @@ -694,7 +828,7 @@ "data": { "description": "Display a chat message in the Playground.", "display_name": "Chat Output", - "id": "ChatOutput-NasE4", + "id": "ChatOutput-64KvA", "node": { "base_classes": ["Message"], "beta": false, @@ -855,8 +989,15 @@ "type": "ChatOutput" }, "height": 385, - "id": "ChatOutput-NasE4", - "position": { "x": 3083.1710516244116, "y": 701.521688846004 }, + "id": "ChatOutput-64KvA", + "measured": { + "height": 385, + "width": 320 + }, + "position": { + "x": 3083.1710516244116, + "y": 701.521688846004 + }, "selected": false, "type": "genericNode", "width": 384 @@ -865,7 +1006,7 @@ "data": { "description": "Retrieves stored chat messages from Langflow tables or an external memory.", "display_name": "Chat Memory", - "id": "Memory-x4ENQ", + "id": "Memory-hrM7A", "node": { "base_classes": ["BaseChatMemory", "Data", "Message"], "beta": false, @@ -900,6 +1041,7 @@ { "cache": true, "display_name": "Messages (Text)", + "hidden": false, "method": "retrieve_messages_as_text", "name": "messages_text", "selected": "Message", @@ -1059,23 +1201,35 @@ }, "dragging": false, "height": 387, - "id": "Memory-x4ENQ", - "position": { "x": 1301.98330242754, "y": 422.33865605652574 }, - "positionAbsolute": { "x": 1301.98330242754, "y": 422.33865605652574 }, + "id": "Memory-hrM7A", + "measured": { + "height": 387, + "width": 320 + }, + "position": { + "x": 1301.98330242754, + "y": 422.33865605652574 + }, + "positionAbsolute": { + "x": 1301.98330242754, + "y": 422.33865605652574 + }, "selected": false, "type": "genericNode", "width": 384 } ], "viewport": { - "x": -377.45799796990354, - "y": 18.161555190942522, - "zoom": 0.45494095964690673 + "x": -345.27561012822684, + "y": 234.6504081003217, + "zoom": 0.2956876380480224 } }, "description": "This project can be used as a starting point for building a Chat experience with user specific memory. You can set a different Session ID to start a new message history.", - "name": "Memory Chatbot (1)", - "last_tested_version": "1.0.15", "endpoint_name": null, - "is_component": false + "id": "03bae731-38bc-4516-ad3c-266040d42668", + "is_component": false, + "last_tested_version": "1.4.0", + "name": "Memory Chatbot", + "tags": [] } diff --git a/src/frontend/tests/extended/features/outdated-actions.spec.ts b/src/frontend/tests/extended/features/outdated-actions.spec.ts index acc8e1c14..f98835c2b 100644 --- a/src/frontend/tests/extended/features/outdated-actions.spec.ts +++ b/src/frontend/tests/extended/features/outdated-actions.spec.ts @@ -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); +});