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 {
Dialog,
@@ -9,16 +8,18 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { toast } from "@/components/ui/use-toast";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { useOnChange } from "@/hooks/useOnChange";
import { useShouldNotifyWhenClosingTab } from "@/hooks/useShouldNotifyWhenClosingTab";
import { DeleteNodeCallbackContext } from "@/store/DeleteNodeCallbackContext";
import { useDebugStore } from "@/store/useDebugStore";
import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore";
import {
useWorkflowHasChangesStore,
useWorkflowSave,
type WorkflowSaveData,
} from "@/store/WorkflowHasChangesStore";
import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore";
import { useWorkflowTitleStore } from "@/store/WorkflowTitleStore";
import { ReloadIcon } from "@radix-ui/react-icons";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
Background,
BackgroundVariant,
@@ -34,11 +35,9 @@ import {
useReactFlow,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { AxiosError } from "axios";
import { nanoid } from "nanoid";
import { useEffect, useRef, useState } from "react";
import { useBlocker, useParams } from "react-router-dom";
import { stringify as convertToYAML } from "yaml";
import { useCallback, useEffect, useRef, useState } from "react";
import { useBlocker } from "react-router-dom";
import {
AWSSecretParameter,
debuggableWorkflowBlockTypes,
@@ -46,18 +45,15 @@ import {
WorkflowEditorParameterTypes,
WorkflowParameterTypes,
WorkflowParameterValueType,
WorkflowSettings,
} from "../types/workflowTypes";
import {
BitwardenCreditCardDataParameterYAML,
BitwardenLoginCredentialParameterYAML,
BitwardenSensitiveInformationParameterYAML,
BlockYAML,
ContextParameterYAML,
CredentialParameterYAML,
OnePasswordCredentialParameterYAML,
ParameterYAML,
WorkflowCreateYAMLRequest,
WorkflowParameterYAML,
} from "../types/workflowYamlTypes";
import { WorkflowHeader } from "./WorkflowHeader";
@@ -265,9 +261,6 @@ function FlowRenderer({
}: Props) {
const reactFlowInstance = useReactFlow();
const debugStore = useDebugStore();
const { workflowPermanentId } = useParams();
const credentialGetter = useCredentialGetter();
const queryClient = useQueryClient();
const { workflowPanelState, setWorkflowPanelState, closeWorkflowPanel } =
useWorkflowPanelStore();
const { title, initializeTitle } = useWorkflowTitleStore();
@@ -289,107 +282,16 @@ function FlowRenderer({
initializeTitle(initialTitle);
}, [initialTitle, initializeTitle]);
const { hasChanges, setHasChanges } = useWorkflowHasChangesStore();
useShouldNotifyWhenClosingTab(hasChanges);
const workflowChangesStore = useWorkflowHasChangesStore();
const setGetSaveDataRef = useRef(workflowChangesStore.setGetSaveData);
setGetSaveDataRef.current = workflowChangesStore.setGetSaveData;
const saveWorkflow = useWorkflowSave();
useShouldNotifyWhenClosingTab(workflowChangesStore.hasChanges);
const blocker = useBlocker(({ currentLocation, nextLocation }) => {
return 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",
});
},
return (
workflowChangesStore.hasChanges &&
nextLocation.pathname !== currentLocation.pathname
);
});
function doLayout(nodes: Array<AppNode>, edges: Array<Edge>) {
@@ -424,7 +326,7 @@ function FlowRenderer({
setDebuggableBlockCount(debuggable.length);
}, [nodes, edges]);
async function handleSave() {
const constructSaveData = useCallback((): WorkflowSaveData => {
const blocks = getWorkflowBlocks(nodes, edges);
const settings = getWorkflowSettings(nodes);
const parametersInYAMLConvertibleJSON = convertToParametersYAML(parameters);
@@ -447,7 +349,7 @@ function FlowRenderer({
overallParameters,
);
return saveWorkflowMutation.mutateAsync({
return {
parameters: [
...echoParameters,
...parametersInYAMLConvertibleJSON,
@@ -456,7 +358,16 @@ function FlowRenderer({
blocks,
title,
settings,
});
workflow,
};
}, [nodes, edges, parameters, title, workflow]);
useEffect(() => {
setGetSaveDataRef.current(constructSaveData);
}, [constructSaveData]);
async function handleSave() {
return await saveWorkflow.mutateAsync();
}
function addNode({
@@ -537,7 +448,7 @@ function FlowRenderer({
...nodes.slice(previousNodeIndex + 1),
];
setHasChanges(true);
workflowChangesStore.setHasChanges(true);
doLayout(newNodesAfter, [...editedEdges, ...newEdges]);
}
@@ -627,7 +538,8 @@ function FlowRenderer({
}
return node;
});
setHasChanges(true);
workflowChangesStore.setHasChanges(true);
doLayout(newNodesWithUpdatedParameters, newEdges);
}
@@ -717,9 +629,9 @@ function FlowRenderer({
blocker.proceed?.();
});
}}
disabled={saveWorkflowMutation.isPending}
disabled={workflowChangesStore.saveIsPending}
>
{saveWorkflowMutation.isPending && (
{workflowChangesStore.saveIsPending && (
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
)}
Save changes
@@ -763,7 +675,7 @@ function FlowRenderer({
);
})
) {
setHasChanges(true);
workflowChangesStore.setHasChanges(true);
}
onNodesChange(changes);
}}
@@ -807,7 +719,7 @@ function FlowRenderer({
<Panel position="top-center" className={cn("h-20")}>
<WorkflowHeader
debuggableBlockCount={debuggableBlockCount}
saving={saveWorkflowMutation.isPending}
saving={workflowChangesStore.saveIsPending}
parametersPanelOpen={
workflowPanelState.active &&
workflowPanelState.content === "parameters"

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,179 @@
import { AxiosError } from "axios";
import { useEffect } from "react";
import { create } from "zustand";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { stringify as convertToYAML } from "yaml";
type WorkflowHasChangesStore = {
hasChanges: boolean;
setHasChanges: (hasChanges: boolean) => void;
import { getClient } from "@/api/AxiosClient";
import { toast } from "@/components/ui/use-toast";
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) => ({
hasChanges: false,
setHasChanges: (hasChanges) => set({ hasChanges }),
}));
type WorkflowHasChangesStore = {
getSaveData: () => SaveData | null;
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,
};