Rework Workflow Save (#3145)

This commit is contained in:
Jonathan Dobson
2025-08-08 16:00:20 -04:00
committed by GitHub
parent 7ed7b531ae
commit 035722c750
6 changed files with 219 additions and 141 deletions

View File

@@ -1,4 +1,3 @@
import { getClient } from "@/api/AxiosClient";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -9,16 +8,18 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { toast } from "@/components/ui/use-toast"; import { toast } from "@/components/ui/use-toast";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { useOnChange } from "@/hooks/useOnChange"; import { useOnChange } from "@/hooks/useOnChange";
import { useShouldNotifyWhenClosingTab } from "@/hooks/useShouldNotifyWhenClosingTab"; import { useShouldNotifyWhenClosingTab } from "@/hooks/useShouldNotifyWhenClosingTab";
import { DeleteNodeCallbackContext } from "@/store/DeleteNodeCallbackContext"; import { DeleteNodeCallbackContext } from "@/store/DeleteNodeCallbackContext";
import { useDebugStore } from "@/store/useDebugStore"; import { useDebugStore } from "@/store/useDebugStore";
import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore"; import {
useWorkflowHasChangesStore,
useWorkflowSave,
type WorkflowSaveData,
} from "@/store/WorkflowHasChangesStore";
import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore"; import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore";
import { useWorkflowTitleStore } from "@/store/WorkflowTitleStore"; import { useWorkflowTitleStore } from "@/store/WorkflowTitleStore";
import { ReloadIcon } from "@radix-ui/react-icons"; import { ReloadIcon } from "@radix-ui/react-icons";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { import {
Background, Background,
BackgroundVariant, BackgroundVariant,
@@ -34,11 +35,9 @@ import {
useReactFlow, useReactFlow,
} from "@xyflow/react"; } from "@xyflow/react";
import "@xyflow/react/dist/style.css"; import "@xyflow/react/dist/style.css";
import { AxiosError } from "axios";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useBlocker, useParams } from "react-router-dom"; import { useBlocker } from "react-router-dom";
import { stringify as convertToYAML } from "yaml";
import { import {
AWSSecretParameter, AWSSecretParameter,
debuggableWorkflowBlockTypes, debuggableWorkflowBlockTypes,
@@ -46,18 +45,15 @@ import {
WorkflowEditorParameterTypes, WorkflowEditorParameterTypes,
WorkflowParameterTypes, WorkflowParameterTypes,
WorkflowParameterValueType, WorkflowParameterValueType,
WorkflowSettings,
} from "../types/workflowTypes"; } from "../types/workflowTypes";
import { import {
BitwardenCreditCardDataParameterYAML, BitwardenCreditCardDataParameterYAML,
BitwardenLoginCredentialParameterYAML, BitwardenLoginCredentialParameterYAML,
BitwardenSensitiveInformationParameterYAML, BitwardenSensitiveInformationParameterYAML,
BlockYAML,
ContextParameterYAML, ContextParameterYAML,
CredentialParameterYAML, CredentialParameterYAML,
OnePasswordCredentialParameterYAML, OnePasswordCredentialParameterYAML,
ParameterYAML, ParameterYAML,
WorkflowCreateYAMLRequest,
WorkflowParameterYAML, WorkflowParameterYAML,
} from "../types/workflowYamlTypes"; } from "../types/workflowYamlTypes";
import { WorkflowHeader } from "./WorkflowHeader"; import { WorkflowHeader } from "./WorkflowHeader";
@@ -265,9 +261,6 @@ function FlowRenderer({
}: Props) { }: Props) {
const reactFlowInstance = useReactFlow(); const reactFlowInstance = useReactFlow();
const debugStore = useDebugStore(); const debugStore = useDebugStore();
const { workflowPermanentId } = useParams();
const credentialGetter = useCredentialGetter();
const queryClient = useQueryClient();
const { workflowPanelState, setWorkflowPanelState, closeWorkflowPanel } = const { workflowPanelState, setWorkflowPanelState, closeWorkflowPanel } =
useWorkflowPanelStore(); useWorkflowPanelStore();
const { title, initializeTitle } = useWorkflowTitleStore(); const { title, initializeTitle } = useWorkflowTitleStore();
@@ -289,107 +282,16 @@ function FlowRenderer({
initializeTitle(initialTitle); initializeTitle(initialTitle);
}, [initialTitle, initializeTitle]); }, [initialTitle, initializeTitle]);
const { hasChanges, setHasChanges } = useWorkflowHasChangesStore(); const workflowChangesStore = useWorkflowHasChangesStore();
useShouldNotifyWhenClosingTab(hasChanges); const setGetSaveDataRef = useRef(workflowChangesStore.setGetSaveData);
setGetSaveDataRef.current = workflowChangesStore.setGetSaveData;
const saveWorkflow = useWorkflowSave();
useShouldNotifyWhenClosingTab(workflowChangesStore.hasChanges);
const blocker = useBlocker(({ currentLocation, nextLocation }) => { const blocker = useBlocker(({ currentLocation, nextLocation }) => {
return hasChanges && nextLocation.pathname !== currentLocation.pathname; return (
}); workflowChangesStore.hasChanges &&
nextLocation.pathname !== currentLocation.pathname
const saveWorkflowMutation = useMutation({ );
mutationFn: async (data: {
parameters: Array<ParameterYAML>;
blocks: Array<BlockYAML>;
title: string;
settings: WorkflowSettings;
}) => {
if (!workflowPermanentId) {
return;
}
const client = await getClient(credentialGetter);
const extraHttpHeaders: Record<string, string> = {};
if (data.settings.extraHttpHeaders) {
try {
const parsedHeaders = JSON.parse(data.settings.extraHttpHeaders);
if (
parsedHeaders &&
typeof parsedHeaders === "object" &&
!Array.isArray(parsedHeaders)
) {
for (const [key, value] of Object.entries(parsedHeaders)) {
if (key && typeof key === "string") {
if (key in extraHttpHeaders) {
toast({
title: "Error",
description: `Duplicate key '${key}' in extra http headers`,
variant: "destructive",
});
continue;
}
extraHttpHeaders[key] = String(value);
}
}
}
} catch (error) {
toast({
title: "Error",
description: "Invalid JSON format in extra http headers",
variant: "destructive",
});
return;
}
}
const requestBody: WorkflowCreateYAMLRequest = {
title: data.title,
description: workflow.description,
proxy_location: data.settings.proxyLocation,
webhook_callback_url: data.settings.webhookCallbackUrl,
persist_browser_session: data.settings.persistBrowserSession,
model: data.settings.model,
max_screenshot_scrolls: data.settings.maxScreenshotScrolls,
totp_verification_url: workflow.totp_verification_url,
extra_http_headers: extraHttpHeaders,
use_cache: data.settings.useScriptCache,
cache_key: data.settings.scriptCacheKey,
workflow_definition: {
parameters: data.parameters,
blocks: data.blocks,
},
is_saved_task: workflow.is_saved_task,
};
const yaml = convertToYAML(requestBody);
return client.put<string, WorkflowApiResponse>(
`/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) => {
const detail = (error.response?.data as { detail?: string })?.detail;
toast({
title: "Error",
description: detail ? detail : error.message,
variant: "destructive",
});
},
}); });
function doLayout(nodes: Array<AppNode>, edges: Array<Edge>) { function doLayout(nodes: Array<AppNode>, edges: Array<Edge>) {
@@ -424,7 +326,7 @@ function FlowRenderer({
setDebuggableBlockCount(debuggable.length); setDebuggableBlockCount(debuggable.length);
}, [nodes, edges]); }, [nodes, edges]);
async function handleSave() { const constructSaveData = useCallback((): WorkflowSaveData => {
const blocks = getWorkflowBlocks(nodes, edges); const blocks = getWorkflowBlocks(nodes, edges);
const settings = getWorkflowSettings(nodes); const settings = getWorkflowSettings(nodes);
const parametersInYAMLConvertibleJSON = convertToParametersYAML(parameters); const parametersInYAMLConvertibleJSON = convertToParametersYAML(parameters);
@@ -447,7 +349,7 @@ function FlowRenderer({
overallParameters, overallParameters,
); );
return saveWorkflowMutation.mutateAsync({ return {
parameters: [ parameters: [
...echoParameters, ...echoParameters,
...parametersInYAMLConvertibleJSON, ...parametersInYAMLConvertibleJSON,
@@ -456,7 +358,16 @@ function FlowRenderer({
blocks, blocks,
title, title,
settings, settings,
}); workflow,
};
}, [nodes, edges, parameters, title, workflow]);
useEffect(() => {
setGetSaveDataRef.current(constructSaveData);
}, [constructSaveData]);
async function handleSave() {
return await saveWorkflow.mutateAsync();
} }
function addNode({ function addNode({
@@ -537,7 +448,7 @@ function FlowRenderer({
...nodes.slice(previousNodeIndex + 1), ...nodes.slice(previousNodeIndex + 1),
]; ];
setHasChanges(true); workflowChangesStore.setHasChanges(true);
doLayout(newNodesAfter, [...editedEdges, ...newEdges]); doLayout(newNodesAfter, [...editedEdges, ...newEdges]);
} }
@@ -627,7 +538,8 @@ function FlowRenderer({
} }
return node; return node;
}); });
setHasChanges(true); workflowChangesStore.setHasChanges(true);
doLayout(newNodesWithUpdatedParameters, newEdges); doLayout(newNodesWithUpdatedParameters, newEdges);
} }
@@ -717,9 +629,9 @@ function FlowRenderer({
blocker.proceed?.(); blocker.proceed?.();
}); });
}} }}
disabled={saveWorkflowMutation.isPending} disabled={workflowChangesStore.saveIsPending}
> >
{saveWorkflowMutation.isPending && ( {workflowChangesStore.saveIsPending && (
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" /> <ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
)} )}
Save changes Save changes
@@ -763,7 +675,7 @@ function FlowRenderer({
); );
}) })
) { ) {
setHasChanges(true); workflowChangesStore.setHasChanges(true);
} }
onNodesChange(changes); onNodesChange(changes);
}} }}
@@ -807,7 +719,7 @@ function FlowRenderer({
<Panel position="top-center" className={cn("h-20")}> <Panel position="top-center" className={cn("h-20")}>
<WorkflowHeader <WorkflowHeader
debuggableBlockCount={debuggableBlockCount} debuggableBlockCount={debuggableBlockCount}
saving={saveWorkflowMutation.isPending} saving={workflowChangesStore.saveIsPending}
parametersPanelOpen={ parametersPanelOpen={
workflowPanelState.active && workflowPanelState.active &&
workflowPanelState.content === "parameters" workflowPanelState.content === "parameters"

View File

@@ -62,9 +62,7 @@ function WorkflowDebugger() {
return state.setCollapsed; return state.setCollapsed;
}); });
const setHasChanges = useWorkflowHasChangesStore( const workflowChangesStore = useWorkflowHasChangesStore();
(state) => state.setHasChanges,
);
const handleOnCycle = () => { const handleOnCycle = () => {
setOpenDialogue(true); setOpenDialogue(true);
@@ -72,7 +70,7 @@ function WorkflowDebugger() {
useMountEffect(() => { useMountEffect(() => {
setCollapsed(true); setCollapsed(true);
setHasChanges(false); workflowChangesStore.setHasChanges(false);
if (workflowPermanentId) { if (workflowPermanentId) {
queryClient.removeQueries({ queryClient.removeQueries({

View File

@@ -16,9 +16,7 @@ function WorkflowEditor() {
const setCollapsed = useSidebarStore((state) => { const setCollapsed = useSidebarStore((state) => {
return state.setCollapsed; return state.setCollapsed;
}); });
const setHasChanges = useWorkflowHasChangesStore( const workflowChangesStore = useWorkflowHasChangesStore();
(state) => state.setHasChanges,
);
const { data: workflow, isLoading } = useWorkflowQuery({ const { data: workflow, isLoading } = useWorkflowQuery({
workflowPermanentId, workflowPermanentId,
@@ -29,7 +27,7 @@ function WorkflowEditor() {
useMountEffect(() => { useMountEffect(() => {
setCollapsed(true); setCollapsed(true);
setHasChanges(false); workflowChangesStore.setHasChanges(false);
}); });
if (isLoading || isGlobalWorkflowsLoading) { if (isLoading || isGlobalWorkflowsLoading) {

View File

@@ -40,7 +40,7 @@ function WorkflowHeader({
saving, saving,
}: Props) { }: Props) {
const { title, setTitle } = useWorkflowTitleStore(); const { title, setTitle } = useWorkflowTitleStore();
const { setHasChanges } = useWorkflowHasChangesStore(); const workflowChangesStore = useWorkflowHasChangesStore();
const { blockLabel: urlBlockLabel, workflowPermanentId } = useParams(); const { blockLabel: urlBlockLabel, workflowPermanentId } = useParams();
const { data: globalWorkflows } = useGlobalWorkflowsQuery(); const { data: globalWorkflows } = useGlobalWorkflowsQuery();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -68,7 +68,7 @@ function WorkflowHeader({
editable={true} editable={true}
onChange={(newTitle) => { onChange={(newTitle) => {
setTitle(newTitle); setTitle(newTitle);
setHasChanges(true); workflowChangesStore.setHasChanges(true);
}} }}
value={title} value={title}
titleClassName="text-3xl" titleClassName="text-3xl"

View File

@@ -21,6 +21,7 @@ import {
} from "@/routes/workflows/types/workflowTypes"; } from "@/routes/workflows/types/workflowTypes";
import { getInitialValues } from "@/routes/workflows/utils"; import { getInitialValues } from "@/routes/workflows/utils";
import { useDebugStore } from "@/store/useDebugStore"; import { useDebugStore } from "@/store/useDebugStore";
import { useWorkflowSave } from "@/store/WorkflowHasChangesStore";
import { import {
useWorkflowSettingsStore, useWorkflowSettingsStore,
type WorkflowSettingsState, type WorkflowSettingsState,
@@ -155,6 +156,7 @@ function NodeHeader({
const { data: debugSession } = useDebugSessionQuery({ const { data: debugSession } = useDebugSessionQuery({
workflowPermanentId, workflowPermanentId,
}); });
const saveWorkflow = useWorkflowSave();
useEffect(() => { useEffect(() => {
if (!workflowRun || !workflowPermanentId || !workflowRunId) { if (!workflowRun || !workflowPermanentId || !workflowRunId) {
@@ -190,6 +192,8 @@ function NodeHeader({
const runBlock = useMutation({ const runBlock = useMutation({
mutationFn: async () => { mutationFn: async () => {
await saveWorkflow.mutateAsync();
if (!workflowPermanentId) { if (!workflowPermanentId) {
console.error("There is no workflowPermanentId"); console.error("There is no workflowPermanentId");
toast({ toast({

View File

@@ -1,13 +1,179 @@
import { AxiosError } from "axios";
import { useEffect } from "react";
import { create } from "zustand"; import { create } from "zustand";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { stringify as convertToYAML } from "yaml";
type WorkflowHasChangesStore = { import { getClient } from "@/api/AxiosClient";
hasChanges: boolean; import { toast } from "@/components/ui/use-toast";
setHasChanges: (hasChanges: boolean) => void; import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import type {
BlockYAML,
ParameterYAML,
} from "@/routes/workflows/types/workflowYamlTypes";
import type {
WorkflowApiResponse,
WorkflowSettings,
} from "@/routes/workflows/types/workflowTypes";
import { WorkflowCreateYAMLRequest } from "@/routes/workflows/types/workflowYamlTypes";
type SaveData = {
parameters: Array<ParameterYAML>;
blocks: Array<BlockYAML>;
title: string;
settings: WorkflowSettings;
workflow: WorkflowApiResponse;
}; };
const useWorkflowHasChangesStore = create<WorkflowHasChangesStore>((set) => ({ type WorkflowHasChangesStore = {
hasChanges: false, getSaveData: () => SaveData | null;
setHasChanges: (hasChanges) => set({ hasChanges }), hasChanges: boolean;
})); saveIsPending: boolean;
setGetSaveData: (getSaveData: () => SaveData) => void;
setHasChanges: (hasChanges: boolean) => void;
setSaveIsPending: (isPending: boolean) => void;
};
export { useWorkflowHasChangesStore }; const useWorkflowHasChangesStore = create<WorkflowHasChangesStore>((set) => {
return {
hasChanges: false,
saveIsPending: false,
getSaveData: () => null,
setGetSaveData: (getSaveData: () => SaveData) => {
set({ getSaveData });
},
setHasChanges: (hasChanges: boolean) => {
set({ hasChanges });
},
setSaveIsPending: (isPending: boolean) => {
set({ saveIsPending: isPending });
},
};
});
const useWorkflowSave = () => {
const credentialGetter = useCredentialGetter();
const queryClient = useQueryClient();
const { getSaveData, setHasChanges, setSaveIsPending } =
useWorkflowHasChangesStore();
const saveWorkflowMutation = useMutation({
mutationFn: async () => {
const saveData = getSaveData();
if (!saveData) {
setHasChanges(false);
return;
}
const client = await getClient(credentialGetter);
const extraHttpHeaders: Record<string, string> = {};
if (saveData.settings.extraHttpHeaders) {
try {
const parsedHeaders = JSON.parse(saveData.settings.extraHttpHeaders);
if (
parsedHeaders &&
typeof parsedHeaders === "object" &&
!Array.isArray(parsedHeaders)
) {
for (const [key, value] of Object.entries(parsedHeaders)) {
if (key && typeof key === "string") {
if (key in extraHttpHeaders) {
toast({
title: "Error",
description: `Duplicate key '${key}' in extra http headers`,
variant: "destructive",
});
continue;
}
extraHttpHeaders[key] = String(value);
}
}
}
} catch (error) {
toast({
title: "Error",
description: "Invalid JSON format in extra http headers",
variant: "destructive",
});
return;
}
}
const requestBody: WorkflowCreateYAMLRequest = {
title: saveData.title,
description: saveData.workflow.description,
proxy_location: saveData.settings.proxyLocation,
webhook_callback_url: saveData.settings.webhookCallbackUrl,
persist_browser_session: saveData.settings.persistBrowserSession,
model: saveData.settings.model,
max_screenshot_scrolls: saveData.settings.maxScreenshotScrolls,
totp_verification_url: saveData.workflow.totp_verification_url,
extra_http_headers: extraHttpHeaders,
use_cache: saveData.settings.useScriptCache,
cache_key: saveData.settings.scriptCacheKey,
workflow_definition: {
parameters: saveData.parameters,
blocks: saveData.blocks,
},
is_saved_task: saveData.workflow.is_saved_task,
};
const yaml = convertToYAML(requestBody);
return client.put<string, WorkflowApiResponse>(
`/workflows/${saveData.workflow.workflow_permanent_id}`,
yaml,
{
headers: {
"Content-Type": "text/plain",
},
},
);
},
onSuccess: () => {
const saveData = getSaveData();
if (!saveData) {
return;
}
toast({
title: "Changes saved",
description: "Your changes have been saved",
variant: "success",
});
queryClient.invalidateQueries({
queryKey: ["workflow", saveData.workflow.workflow_permanent_id],
});
queryClient.invalidateQueries({
queryKey: ["workflows"],
});
setHasChanges(false);
},
onError: (error: AxiosError) => {
const detail = (error.response?.data as { detail?: string })?.detail;
toast({
title: "Error",
description: detail ? detail : error.message,
variant: "destructive",
});
},
});
useEffect(() => {
setSaveIsPending(saveWorkflowMutation.isPending);
}, [saveWorkflowMutation.isPending, setSaveIsPending]);
return saveWorkflowMutation;
};
export {
useWorkflowSave,
useWorkflowHasChangesStore,
type SaveData as WorkflowSaveData,
};