diff --git a/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx b/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx index 7740d2330..5f7658cf1 100644 --- a/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx @@ -14,10 +14,18 @@ import { import "@xyflow/react/dist/style.css"; import { nanoid } from "nanoid"; import { useEffect, useState } from "react"; -import { WorkflowParameterValueType } from "../types/workflowTypes"; +import { + AWSSecretParameter, + BitwardenSensitiveInformationParameter, + ContextParameter, + WorkflowApiResponse, + WorkflowParameterValueType, +} from "../types/workflowTypes"; import { BitwardenLoginCredentialParameterYAML, BlockYAML, + ParameterYAML, + WorkflowCreateYAMLRequest, WorkflowParameterYAML, } from "../types/workflowYamlTypes"; import { WorkflowHeader } from "./WorkflowHeader"; @@ -28,12 +36,32 @@ import { WorkflowNodeLibraryPanel } from "./panels/WorkflowNodeLibraryPanel"; import { WorkflowParametersPanel } from "./panels/WorkflowParametersPanel"; import "./reactFlowOverrideStyles.css"; import { + convertEchoParameters, createNode, generateNodeLabel, + getAdditionalParametersForEmailBlock, getOutputParameterKey, getWorkflowBlocks, layout, } from "./workflowEditorUtils"; +import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore"; +import { useBlocker, useParams } from "react-router-dom"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { getClient } from "@/api/AxiosClient"; +import { useCredentialGetter } from "@/hooks/useCredentialGetter"; +import { stringify as convertToYAML } from "yaml"; +import { toast } from "@/components/ui/use-toast"; +import { AxiosError } from "axios"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { ReloadIcon } from "@radix-ui/react-icons"; function convertToParametersYAML( parameters: ParametersState, @@ -85,13 +113,7 @@ type Props = { initialNodes: Array; initialEdges: Array; initialParameters: ParametersState; - handleSave: ( - parameters: Array< - WorkflowParameterYAML | BitwardenLoginCredentialParameterYAML - >, - blocks: Array, - title: string, - ) => void; + workflow: WorkflowApiResponse; }; export type AddNodeProps = { @@ -107,8 +129,11 @@ function FlowRenderer({ initialEdges, initialNodes, initialParameters, - handleSave, + workflow, }: Props) { + const { workflowPermanentId } = useParams(); + const credentialGetter = useCredentialGetter(); + const queryClient = useQueryClient(); const { workflowPanelState, setWorkflowPanelState, closeWorkflowPanel } = useWorkflowPanelStore(); const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); @@ -116,6 +141,66 @@ function FlowRenderer({ const [parameters, setParameters] = useState(initialParameters); const [title, setTitle] = useState(initialTitle); const nodesInitialized = useNodesInitialized(); + const { hasChanges, setHasChanges } = useWorkflowHasChangesStore(); + const blocker = useBlocker(({ currentLocation, nextLocation }) => { + return hasChanges && nextLocation.pathname !== currentLocation.pathname; + }); + + const saveWorkflowMutation = useMutation({ + mutationFn: async (data: { + parameters: Array; + blocks: Array; + title: string; + }) => { + if (!workflowPermanentId) { + return; + } + const client = await getClient(credentialGetter); + const requestBody: WorkflowCreateYAMLRequest = { + title: data.title, + description: workflow.description, + proxy_location: workflow.proxy_location, + webhook_callback_url: workflow.webhook_callback_url, + totp_verification_url: workflow.totp_verification_url, + workflow_definition: { + parameters: data.parameters, + blocks: data.blocks, + }, + is_saved_task: workflow.is_saved_task, + }; + const yaml = convertToYAML(requestBody); + return client.put( + `/workflows/${workflowPermanentId}`, + yaml, + { + headers: { + "Content-Type": "text/plain", + }, + }, + ); + }, + onSuccess: () => { + toast({ + title: "Changes saved", + description: "Your changes have been saved", + variant: "success", + }); + queryClient.invalidateQueries({ + queryKey: ["workflow", workflowPermanentId], + }); + queryClient.invalidateQueries({ + queryKey: ["workflows"], + }); + setHasChanges(false); + }, + onError: (error: AxiosError) => { + toast({ + title: "Error", + description: error.message, + variant: "destructive", + }); + }, + }); function doLayout(nodes: Array, edges: Array) { const layoutedElements = layout(nodes, edges); @@ -130,6 +215,47 @@ function FlowRenderer({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [nodesInitialized]); + async function handleSave() { + const blocks = getWorkflowBlocks(nodes); + const parametersInYAMLConvertibleJSON = convertToParametersYAML(parameters); + const filteredParameters = workflow.workflow_definition.parameters.filter( + (parameter) => { + return ( + parameter.parameter_type === "aws_secret" || + parameter.parameter_type === "bitwarden_sensitive_information" || + parameter.parameter_type === "context" + ); + }, + ) as Array< + | AWSSecretParameter + | BitwardenSensitiveInformationParameter + | ContextParameter + >; + + const echoParameters = convertEchoParameters(filteredParameters); + + const overallParameters = [ + ...parameters, + ...echoParameters, + ] as Array; + + // if there is an email node, we need to add the email aws secret parameters + const emailAwsSecretParameters = getAdditionalParametersForEmailBlock( + blocks, + overallParameters, + ); + + return saveWorkflowMutation.mutateAsync({ + parameters: [ + ...echoParameters, + ...parametersInYAMLConvertibleJSON, + ...emailAwsSecretParameters, + ], + blocks, + title, + }); + } + function addNode({ nodeType, previous, @@ -220,7 +346,7 @@ function FlowRenderer({ }, }); } - + setHasChanges(true); doLayout(newNodesAfter, [...editedEdges, ...newEdges]); } @@ -275,110 +401,160 @@ function FlowRenderer({ } return node; }); - + setHasChanges(true); doLayout(newNodesWithUpdatedParameters, newEdges); } return ( - - - { - const dimensionChanges = changes.filter( - (change) => change.type === "dimensions", - ); - const tempNodes = [...nodes]; - dimensionChanges.forEach((change) => { - const node = tempNodes.find((node) => node.id === change.id); - if (node) { - if (node.measured?.width) { - node.measured.width = change.dimensions?.width; - } - if (node.measured?.height) { - node.measured.height = change.dimensions?.height; + <> + { + if (!open) { + blocker.reset?.(); + } + }} + > + + + Unsaved Changes + + Your workflow has unsaved changes. Do you want to save them before + leaving? + + + + + + + + + + + { + const dimensionChanges = changes.filter( + (change) => change.type === "dimensions", + ); + const tempNodes = [...nodes]; + dimensionChanges.forEach((change) => { + const node = tempNodes.find((node) => node.id === change.id); + if (node) { + if (node.measured?.width) { + node.measured.width = change.dimensions?.width; + } + if (node.measured?.height) { + node.measured.height = change.dimensions?.height; + } } + }); + if (dimensionChanges.length > 0) { + doLayout(tempNodes, edges); } - }); - if (dimensionChanges.length > 0) { - doLayout(tempNodes, edges); - } - onNodesChange(changes); - }} - onEdgesChange={onEdgesChange} - nodeTypes={nodeTypes} - edgeTypes={edgeTypes} - colorMode="dark" - fitView - fitViewOptions={{ - maxZoom: 1, - }} - > - - - - { + return ( + change.type === "add" || + change.type === "remove" || + change.type === "replace" + ); + }) + ) { + setHasChanges(true); } - onParametersClick={() => { - if ( + onNodesChange(changes); + }} + onEdgesChange={onEdgesChange} + nodeTypes={nodeTypes} + edgeTypes={edgeTypes} + colorMode="dark" + fitView + fitViewOptions={{ + maxZoom: 1, + }} + > + + + + { + setTitle(newTitle); + setHasChanges(true); + }} + parametersPanelOpen={ workflowPanelState.active && workflowPanelState.content === "parameters" - ) { - closeWorkflowPanel(); - } else { - setWorkflowPanelState({ - active: true, - content: "parameters", - }); } - }} - onSave={() => { - const blocksInYAMLConvertibleJSON = getWorkflowBlocks(nodes); - const parametersInYAMLConvertibleJSON = - convertToParametersYAML(parameters); - handleSave( - parametersInYAMLConvertibleJSON, - blocksInYAMLConvertibleJSON, - title, - ); - }} - /> - - {workflowPanelState.active && ( - - {workflowPanelState.content === "parameters" && ( - - )} - {workflowPanelState.content === "nodeLibrary" && ( + onParametersClick={() => { + if ( + workflowPanelState.active && + workflowPanelState.content === "parameters" + ) { + closeWorkflowPanel(); + } else { + setWorkflowPanelState({ + active: true, + content: "parameters", + }); + } + }} + onSave={async () => { + await handleSave(); + }} + /> + + {workflowPanelState.active && ( + + {workflowPanelState.content === "parameters" && ( + + )} + {workflowPanelState.content === "nodeLibrary" && ( + { + addNode(props); + }} + /> + )} + + )} + {nodes.length === 0 && ( + { addNode(props); }} + first /> - )} - - )} - {nodes.length === 0 && ( - - { - addNode(props); - }} - first - /> - - )} - - - + + )} + + + + ); } diff --git a/skyvern-frontend/src/routes/workflows/editor/WorkflowEditor.tsx b/skyvern-frontend/src/routes/workflows/editor/WorkflowEditor.tsx index baf50cb64..37b3e8b96 100644 --- a/skyvern-frontend/src/routes/workflows/editor/WorkflowEditor.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/WorkflowEditor.tsx @@ -1,38 +1,20 @@ +import { useMountEffect } from "@/hooks/useMountEffect"; +import { useSidebarStore } from "@/store/SidebarStore"; +import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore"; +import { ReactFlowProvider } from "@xyflow/react"; import { useParams } from "react-router-dom"; import { useWorkflowQuery } from "../hooks/useWorkflowQuery"; -import { - convertEchoParameters, - getAdditionalParametersForEmailBlock, - getElements, -} from "./workflowEditorUtils"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { - BlockYAML, - ParameterYAML, - WorkflowCreateYAMLRequest, -} from "../types/workflowYamlTypes"; -import { getClient } from "@/api/AxiosClient"; -import { useCredentialGetter } from "@/hooks/useCredentialGetter"; -import { stringify as convertToYAML } from "yaml"; -import { ReactFlowProvider } from "@xyflow/react"; import { FlowRenderer } from "./FlowRenderer"; -import { toast } from "@/components/ui/use-toast"; -import { AxiosError } from "axios"; -import { - AWSSecretParameter, - BitwardenSensitiveInformationParameter, - ContextParameter, -} from "../types/workflowTypes"; -import { useSidebarStore } from "@/store/SidebarStore"; -import { useMountEffect } from "@/hooks/useMountEffect"; +import { getElements } from "./workflowEditorUtils"; function WorkflowEditor() { const { workflowPermanentId } = useParams(); - const credentialGetter = useCredentialGetter(); - const queryClient = useQueryClient(); const setCollapsed = useSidebarStore((state) => { return state.setCollapsed; }); + const setHasChanges = useWorkflowHasChangesStore( + (state) => state.setHasChanges, + ); const { data: workflow, isLoading } = useWorkflowQuery({ workflowPermanentId, @@ -40,59 +22,7 @@ function WorkflowEditor() { useMountEffect(() => { setCollapsed(true); - }); - - const saveWorkflowMutation = useMutation({ - mutationFn: async (data: { - parameters: Array; - blocks: Array; - title: string; - }) => { - if (!workflow || !workflowPermanentId) { - return; - } - const client = await getClient(credentialGetter); - const requestBody: WorkflowCreateYAMLRequest = { - title: data.title, - description: workflow.description, - proxy_location: workflow.proxy_location, - webhook_callback_url: workflow.webhook_callback_url, - totp_verification_url: workflow.totp_verification_url, - workflow_definition: { - parameters: data.parameters, - blocks: data.blocks, - }, - is_saved_task: workflow.is_saved_task, - }; - const yaml = convertToYAML(requestBody); - return client - .put(`/workflows/${workflowPermanentId}`, yaml, { - headers: { - "Content-Type": "text/plain", - }, - }) - .then((response) => response.data); - }, - onSuccess: () => { - toast({ - title: "Changes saved", - description: "Your changes have been saved", - variant: "success", - }); - queryClient.invalidateQueries({ - queryKey: ["workflow", workflowPermanentId], - }); - queryClient.invalidateQueries({ - queryKey: ["workflows"], - }); - }, - onError: (error: AxiosError) => { - toast({ - title: "Error", - description: error.message, - variant: "destructive", - }); - }, + setHasChanges(false); }); // TODO @@ -139,42 +69,7 @@ function WorkflowEditor() { }; } })} - handleSave={(parameters, blocks, title) => { - const filteredParameters = - workflow.workflow_definition.parameters.filter((parameter) => { - return ( - parameter.parameter_type === "aws_secret" || - parameter.parameter_type === - "bitwarden_sensitive_information" || - parameter.parameter_type === "context" - ); - }) as Array< - | AWSSecretParameter - | BitwardenSensitiveInformationParameter - | ContextParameter - >; - - const echoParameters = convertEchoParameters(filteredParameters); - - const overallParameters = [ - ...parameters, - ...echoParameters, - ] as Array; - - // if there is an email node, we need to add the email aws secret parameters - const emailAwsSecretParameters = - getAdditionalParametersForEmailBlock(blocks, overallParameters); - - saveWorkflowMutation.mutate({ - parameters: [ - ...echoParameters, - ...parameters, - ...emailAwsSecretParameters, - ], - blocks, - title, - }); - }} + workflow={workflow} /> diff --git a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowParametersPanel.tsx b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowParametersPanel.tsx index 1e40f9955..1881031be 100644 --- a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowParametersPanel.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowParametersPanel.tsx @@ -25,11 +25,15 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { useReactFlow } from "@xyflow/react"; +import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore"; const WORKFLOW_EDIT_PANEL_WIDTH = 20 * 16; const WORKFLOW_EDIT_PANEL_GAP = 1 * 16; function WorkflowParametersPanel() { + const setHasChanges = useWorkflowHasChangesStore( + (state) => state.setHasChanges, + ); const [workflowParameters, setWorkflowParameters] = useWorkflowParametersState(); const [operationPanelState, setOperationPanelState] = useState<{ @@ -144,6 +148,7 @@ function WorkflowParametersPanel() { (p) => p.key !== parameter.key, ), ); + setHasChanges(true); setNodes((nodes) => { return nodes.map((node) => { if (node.type === "task") { @@ -187,6 +192,7 @@ function WorkflowParametersPanel() { type={operationPanelState.type} onSave={(parameter) => { setWorkflowParameters([...workflowParameters, parameter]); + setHasChanges(true); setOperationPanelState({ active: false, operation: "add", @@ -210,6 +216,7 @@ function WorkflowParametersPanel() { type={operationPanelState.type} initialValues={operationPanelState.parameter} onSave={(editedParameter) => { + setHasChanges(true); setWorkflowParameters( workflowParameters.map((parameter) => { if ( diff --git a/skyvern-frontend/src/store/WorkflowHasChangesStore.ts b/skyvern-frontend/src/store/WorkflowHasChangesStore.ts new file mode 100644 index 000000000..47a1d5b3f --- /dev/null +++ b/skyvern-frontend/src/store/WorkflowHasChangesStore.ts @@ -0,0 +1,13 @@ +import { create } from "zustand"; + +type WorkflowHasChangesStore = { + hasChanges: boolean; + setHasChanges: (hasChanges: boolean) => void; +}; + +const useWorkflowHasChangesStore = create((set) => ({ + hasChanges: false, + setHasChanges: (hasChanges) => set({ hasChanges }), +})); + +export { useWorkflowHasChangesStore };