Implement 'you have unsaved changes' (#850)

This commit is contained in:
Kerem Yilmaz
2024-09-18 11:46:58 -07:00
committed by GitHub
parent 436fcd9620
commit 896c9c8956
4 changed files with 305 additions and 214 deletions

View File

@@ -14,10 +14,18 @@ import {
import "@xyflow/react/dist/style.css"; import "@xyflow/react/dist/style.css";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { WorkflowParameterValueType } from "../types/workflowTypes"; import {
AWSSecretParameter,
BitwardenSensitiveInformationParameter,
ContextParameter,
WorkflowApiResponse,
WorkflowParameterValueType,
} from "../types/workflowTypes";
import { import {
BitwardenLoginCredentialParameterYAML, BitwardenLoginCredentialParameterYAML,
BlockYAML, BlockYAML,
ParameterYAML,
WorkflowCreateYAMLRequest,
WorkflowParameterYAML, WorkflowParameterYAML,
} from "../types/workflowYamlTypes"; } from "../types/workflowYamlTypes";
import { WorkflowHeader } from "./WorkflowHeader"; import { WorkflowHeader } from "./WorkflowHeader";
@@ -28,12 +36,32 @@ import { WorkflowNodeLibraryPanel } from "./panels/WorkflowNodeLibraryPanel";
import { WorkflowParametersPanel } from "./panels/WorkflowParametersPanel"; import { WorkflowParametersPanel } from "./panels/WorkflowParametersPanel";
import "./reactFlowOverrideStyles.css"; import "./reactFlowOverrideStyles.css";
import { import {
convertEchoParameters,
createNode, createNode,
generateNodeLabel, generateNodeLabel,
getAdditionalParametersForEmailBlock,
getOutputParameterKey, getOutputParameterKey,
getWorkflowBlocks, getWorkflowBlocks,
layout, layout,
} from "./workflowEditorUtils"; } 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( function convertToParametersYAML(
parameters: ParametersState, parameters: ParametersState,
@@ -85,13 +113,7 @@ type Props = {
initialNodes: Array<AppNode>; initialNodes: Array<AppNode>;
initialEdges: Array<Edge>; initialEdges: Array<Edge>;
initialParameters: ParametersState; initialParameters: ParametersState;
handleSave: ( workflow: WorkflowApiResponse;
parameters: Array<
WorkflowParameterYAML | BitwardenLoginCredentialParameterYAML
>,
blocks: Array<BlockYAML>,
title: string,
) => void;
}; };
export type AddNodeProps = { export type AddNodeProps = {
@@ -107,8 +129,11 @@ function FlowRenderer({
initialEdges, initialEdges,
initialNodes, initialNodes,
initialParameters, initialParameters,
handleSave, workflow,
}: Props) { }: Props) {
const { workflowPermanentId } = useParams();
const credentialGetter = useCredentialGetter();
const queryClient = useQueryClient();
const { workflowPanelState, setWorkflowPanelState, closeWorkflowPanel } = const { workflowPanelState, setWorkflowPanelState, closeWorkflowPanel } =
useWorkflowPanelStore(); useWorkflowPanelStore();
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
@@ -116,6 +141,66 @@ function FlowRenderer({
const [parameters, setParameters] = useState(initialParameters); const [parameters, setParameters] = useState(initialParameters);
const [title, setTitle] = useState(initialTitle); const [title, setTitle] = useState(initialTitle);
const nodesInitialized = useNodesInitialized(); 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<ParameterYAML>;
blocks: Array<BlockYAML>;
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<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) => {
toast({
title: "Error",
description: error.message,
variant: "destructive",
});
},
});
function doLayout(nodes: Array<AppNode>, edges: Array<Edge>) { function doLayout(nodes: Array<AppNode>, edges: Array<Edge>) {
const layoutedElements = layout(nodes, edges); const layoutedElements = layout(nodes, edges);
@@ -130,6 +215,47 @@ function FlowRenderer({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [nodesInitialized]); }, [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<ParameterYAML>;
// 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({ function addNode({
nodeType, nodeType,
previous, previous,
@@ -220,7 +346,7 @@ function FlowRenderer({
}, },
}); });
} }
setHasChanges(true);
doLayout(newNodesAfter, [...editedEdges, ...newEdges]); doLayout(newNodesAfter, [...editedEdges, ...newEdges]);
} }
@@ -275,110 +401,160 @@ function FlowRenderer({
} }
return node; return node;
}); });
setHasChanges(true);
doLayout(newNodesWithUpdatedParameters, newEdges); doLayout(newNodesWithUpdatedParameters, newEdges);
} }
return ( return (
<WorkflowParametersStateContext.Provider <>
value={[parameters, setParameters]} <Dialog
> open={blocker.state === "blocked"}
<DeleteNodeCallbackContext.Provider value={deleteNode}> onOpenChange={(open) => {
<ReactFlow if (!open) {
nodes={nodes} blocker.reset?.();
edges={edges} }
onNodesChange={(changes) => { }}
const dimensionChanges = changes.filter( >
(change) => change.type === "dimensions", <DialogContent>
); <DialogHeader>
const tempNodes = [...nodes]; <DialogTitle>Unsaved Changes</DialogTitle>
dimensionChanges.forEach((change) => { <DialogDescription>
const node = tempNodes.find((node) => node.id === change.id); Your workflow has unsaved changes. Do you want to save them before
if (node) { leaving?
if (node.measured?.width) { </DialogDescription>
node.measured.width = change.dimensions?.width; </DialogHeader>
} <DialogFooter>
if (node.measured?.height) { <Button
node.measured.height = change.dimensions?.height; variant="secondary"
onClick={() => {
blocker.proceed?.();
}}
>
Continue without saving
</Button>
<Button
onClick={() => {
handleSave().then(() => {
blocker.proceed?.();
});
}}
disabled={saveWorkflowMutation.isPending}
>
{saveWorkflowMutation.isPending && (
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
)}
Save changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<WorkflowParametersStateContext.Provider
value={[parameters, setParameters]}
>
<DeleteNodeCallbackContext.Provider value={deleteNode}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={(changes) => {
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 (
if (dimensionChanges.length > 0) { changes.some((change) => {
doLayout(tempNodes, edges); return (
} change.type === "add" ||
onNodesChange(changes); change.type === "remove" ||
}} change.type === "replace"
onEdgesChange={onEdgesChange} );
nodeTypes={nodeTypes} })
edgeTypes={edgeTypes} ) {
colorMode="dark" setHasChanges(true);
fitView
fitViewOptions={{
maxZoom: 1,
}}
>
<Background variant={BackgroundVariant.Dots} bgColor="#020617" />
<Controls position="bottom-left" />
<Panel position="top-center" className="h-20">
<WorkflowHeader
title={title}
onTitleChange={setTitle}
parametersPanelOpen={
workflowPanelState.active &&
workflowPanelState.content === "parameters"
} }
onParametersClick={() => { onNodesChange(changes);
if ( }}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
colorMode="dark"
fitView
fitViewOptions={{
maxZoom: 1,
}}
>
<Background variant={BackgroundVariant.Dots} bgColor="#020617" />
<Controls position="bottom-left" />
<Panel position="top-center" className="h-20">
<WorkflowHeader
title={title}
onTitleChange={(newTitle) => {
setTitle(newTitle);
setHasChanges(true);
}}
parametersPanelOpen={
workflowPanelState.active && workflowPanelState.active &&
workflowPanelState.content === "parameters" workflowPanelState.content === "parameters"
) {
closeWorkflowPanel();
} else {
setWorkflowPanelState({
active: true,
content: "parameters",
});
} }
}} onParametersClick={() => {
onSave={() => { if (
const blocksInYAMLConvertibleJSON = getWorkflowBlocks(nodes); workflowPanelState.active &&
const parametersInYAMLConvertibleJSON = workflowPanelState.content === "parameters"
convertToParametersYAML(parameters); ) {
handleSave( closeWorkflowPanel();
parametersInYAMLConvertibleJSON, } else {
blocksInYAMLConvertibleJSON, setWorkflowPanelState({
title, active: true,
); content: "parameters",
}} });
/> }
</Panel> }}
{workflowPanelState.active && ( onSave={async () => {
<Panel position="top-right"> await handleSave();
{workflowPanelState.content === "parameters" && ( }}
<WorkflowParametersPanel /> />
)} </Panel>
{workflowPanelState.content === "nodeLibrary" && ( {workflowPanelState.active && (
<Panel position="top-right">
{workflowPanelState.content === "parameters" && (
<WorkflowParametersPanel />
)}
{workflowPanelState.content === "nodeLibrary" && (
<WorkflowNodeLibraryPanel
onNodeClick={(props) => {
addNode(props);
}}
/>
)}
</Panel>
)}
{nodes.length === 0 && (
<Panel position="top-right">
<WorkflowNodeLibraryPanel <WorkflowNodeLibraryPanel
onNodeClick={(props) => { onNodeClick={(props) => {
addNode(props); addNode(props);
}} }}
first
/> />
)} </Panel>
</Panel> )}
)} </ReactFlow>
{nodes.length === 0 && ( </DeleteNodeCallbackContext.Provider>
<Panel position="top-right"> </WorkflowParametersStateContext.Provider>
<WorkflowNodeLibraryPanel </>
onNodeClick={(props) => {
addNode(props);
}}
first
/>
</Panel>
)}
</ReactFlow>
</DeleteNodeCallbackContext.Provider>
</WorkflowParametersStateContext.Provider>
); );
} }

View File

@@ -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 { useParams } from "react-router-dom";
import { useWorkflowQuery } from "../hooks/useWorkflowQuery"; 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 { FlowRenderer } from "./FlowRenderer";
import { toast } from "@/components/ui/use-toast"; import { getElements } from "./workflowEditorUtils";
import { AxiosError } from "axios";
import {
AWSSecretParameter,
BitwardenSensitiveInformationParameter,
ContextParameter,
} from "../types/workflowTypes";
import { useSidebarStore } from "@/store/SidebarStore";
import { useMountEffect } from "@/hooks/useMountEffect";
function WorkflowEditor() { function WorkflowEditor() {
const { workflowPermanentId } = useParams(); const { workflowPermanentId } = useParams();
const credentialGetter = useCredentialGetter();
const queryClient = useQueryClient();
const setCollapsed = useSidebarStore((state) => { const setCollapsed = useSidebarStore((state) => {
return state.setCollapsed; return state.setCollapsed;
}); });
const setHasChanges = useWorkflowHasChangesStore(
(state) => state.setHasChanges,
);
const { data: workflow, isLoading } = useWorkflowQuery({ const { data: workflow, isLoading } = useWorkflowQuery({
workflowPermanentId, workflowPermanentId,
@@ -40,59 +22,7 @@ function WorkflowEditor() {
useMountEffect(() => { useMountEffect(() => {
setCollapsed(true); setCollapsed(true);
}); setHasChanges(false);
const saveWorkflowMutation = useMutation({
mutationFn: async (data: {
parameters: Array<ParameterYAML>;
blocks: Array<BlockYAML>;
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",
});
},
}); });
// TODO // TODO
@@ -139,42 +69,7 @@ function WorkflowEditor() {
}; };
} }
})} })}
handleSave={(parameters, blocks, title) => { workflow={workflow}
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<ParameterYAML>;
// 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,
});
}}
/> />
</ReactFlowProvider> </ReactFlowProvider>
</div> </div>

View File

@@ -25,11 +25,15 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { useReactFlow } from "@xyflow/react"; import { useReactFlow } from "@xyflow/react";
import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore";
const WORKFLOW_EDIT_PANEL_WIDTH = 20 * 16; const WORKFLOW_EDIT_PANEL_WIDTH = 20 * 16;
const WORKFLOW_EDIT_PANEL_GAP = 1 * 16; const WORKFLOW_EDIT_PANEL_GAP = 1 * 16;
function WorkflowParametersPanel() { function WorkflowParametersPanel() {
const setHasChanges = useWorkflowHasChangesStore(
(state) => state.setHasChanges,
);
const [workflowParameters, setWorkflowParameters] = const [workflowParameters, setWorkflowParameters] =
useWorkflowParametersState(); useWorkflowParametersState();
const [operationPanelState, setOperationPanelState] = useState<{ const [operationPanelState, setOperationPanelState] = useState<{
@@ -144,6 +148,7 @@ function WorkflowParametersPanel() {
(p) => p.key !== parameter.key, (p) => p.key !== parameter.key,
), ),
); );
setHasChanges(true);
setNodes((nodes) => { setNodes((nodes) => {
return nodes.map((node) => { return nodes.map((node) => {
if (node.type === "task") { if (node.type === "task") {
@@ -187,6 +192,7 @@ function WorkflowParametersPanel() {
type={operationPanelState.type} type={operationPanelState.type}
onSave={(parameter) => { onSave={(parameter) => {
setWorkflowParameters([...workflowParameters, parameter]); setWorkflowParameters([...workflowParameters, parameter]);
setHasChanges(true);
setOperationPanelState({ setOperationPanelState({
active: false, active: false,
operation: "add", operation: "add",
@@ -210,6 +216,7 @@ function WorkflowParametersPanel() {
type={operationPanelState.type} type={operationPanelState.type}
initialValues={operationPanelState.parameter} initialValues={operationPanelState.parameter}
onSave={(editedParameter) => { onSave={(editedParameter) => {
setHasChanges(true);
setWorkflowParameters( setWorkflowParameters(
workflowParameters.map((parameter) => { workflowParameters.map((parameter) => {
if ( if (

View File

@@ -0,0 +1,13 @@
import { create } from "zustand";
type WorkflowHasChangesStore = {
hasChanges: boolean;
setHasChanges: (hasChanges: boolean) => void;
};
const useWorkflowHasChangesStore = create<WorkflowHasChangesStore>((set) => ({
hasChanges: false,
setHasChanges: (hasChanges) => set({ hasChanges }),
}));
export { useWorkflowHasChangesStore };