Deletable nodes (#801)
Co-authored-by: Muhammed Salih Altun <muhammedsalihaltun@gmail.com>
This commit is contained in:
28
skyvern-frontend/package-lock.json
generated
28
skyvern-frontend/package-lock.json
generated
@@ -39,6 +39,7 @@
|
||||
"embla-carousel-react": "^8.0.0",
|
||||
"express": "^4.19.2",
|
||||
"fetch-to-curl": "^0.6.0",
|
||||
"nanoid": "^5.0.7",
|
||||
"open": "^10.1.0",
|
||||
"posthog-js": "^1.138.0",
|
||||
"react": "^18.2.0",
|
||||
@@ -5829,9 +5830,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.7",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
|
||||
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.7.tgz",
|
||||
"integrity": "sha512-oLxFY2gd2IqnjcYyOXD8XGCftpGtZP2AbHbOkthDkvRywH5ayNtPVy9YlOPcHckXzbLTCHpkb7FB+yuxKV13pQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -5839,10 +5840,10 @@
|
||||
}
|
||||
],
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
"nanoid": "bin/nanoid.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
"node": "^18 || >=20"
|
||||
}
|
||||
},
|
||||
"node_modules/natural-compare": {
|
||||
@@ -6299,6 +6300,23 @@
|
||||
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="
|
||||
},
|
||||
"node_modules/postcss/node_modules/nanoid": {
|
||||
"version": "3.3.7",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
|
||||
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/posthog-js": {
|
||||
"version": "1.138.0",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.138.0.tgz",
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"embla-carousel-react": "^8.0.0",
|
||||
"express": "^4.19.2",
|
||||
"fetch-to-curl": "^0.6.0",
|
||||
"nanoid": "^5.0.7",
|
||||
"open": "^10.1.0",
|
||||
"posthog-js": "^1.138.0",
|
||||
"react": "^18.2.0",
|
||||
|
||||
@@ -13,7 +13,12 @@ import "@xyflow/react/dist/style.css";
|
||||
import { WorkflowHeader } from "./WorkflowHeader";
|
||||
import { AppNode, nodeTypes } from "./nodes";
|
||||
import "./reactFlowOverrideStyles.css";
|
||||
import { createNode, getWorkflowBlocks, layout } from "./workflowEditorUtils";
|
||||
import {
|
||||
createNode,
|
||||
generateNodeLabel,
|
||||
getWorkflowBlocks,
|
||||
layout,
|
||||
} from "./workflowEditorUtils";
|
||||
import { useEffect, useState } from "react";
|
||||
import { WorkflowParametersPanel } from "./panels/WorkflowParametersPanel";
|
||||
import { edgeTypes } from "./edges";
|
||||
@@ -26,6 +31,8 @@ import {
|
||||
} from "../types/workflowYamlTypes";
|
||||
import { WorkflowParametersStateContext } from "./WorkflowParametersStateContext";
|
||||
import { WorkflowParameterValueType } from "../types/workflowTypes";
|
||||
import { DeleteNodeCallbackContext } from "@/store/DeleteNodeCallbackContext";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
function convertToParametersYAML(
|
||||
parameters: ParametersState,
|
||||
@@ -131,11 +138,12 @@ function FlowRenderer({
|
||||
}: AddNodeProps) {
|
||||
const newNodes: Array<AppNode> = [];
|
||||
const newEdges: Array<Edge> = [];
|
||||
const index = parent
|
||||
? nodes.filter((node) => node.parentId === parent).length
|
||||
: nodes.length;
|
||||
const id = parent ? `${parent}-${index}` : String(index);
|
||||
const node = createNode({ id, parentId: parent }, nodeType, String(index));
|
||||
const id = nanoid();
|
||||
const node = createNode(
|
||||
{ id, parentId: parent },
|
||||
nodeType,
|
||||
generateNodeLabel(nodes.map((node) => node.data.label)),
|
||||
);
|
||||
newNodes.push(node);
|
||||
if (previous) {
|
||||
const newEdge = {
|
||||
@@ -163,6 +171,7 @@ function FlowRenderer({
|
||||
}
|
||||
|
||||
if (nodeType === "loop") {
|
||||
// when loop node is first created it needs an adder node so nodes can be added inside the loop
|
||||
newNodes.push({
|
||||
id: `${id}-nodeAdder`,
|
||||
type: "nodeAdder",
|
||||
@@ -183,6 +192,7 @@ function FlowRenderer({
|
||||
? nodes.indexOf(previousNode)
|
||||
: nodes.length - 1;
|
||||
|
||||
// creating some memory for no reason, maybe check it out later
|
||||
const newNodesAfter = [
|
||||
...nodes.slice(0, previousNodeIndex + 1),
|
||||
...newNodes,
|
||||
@@ -190,6 +200,7 @@ function FlowRenderer({
|
||||
];
|
||||
|
||||
if (nodes.length === 0) {
|
||||
// if there were no nodes before, add a nodeAdder node and connect it to the new node
|
||||
newNodesAfter.push({
|
||||
id: `${id}-nodeAdder`,
|
||||
type: "nodeAdder",
|
||||
@@ -212,103 +223,142 @@ function FlowRenderer({
|
||||
doLayout(newNodesAfter, [...editedEdges, ...newEdges]);
|
||||
}
|
||||
|
||||
function deleteNode(id: string) {
|
||||
const node = nodes.find((node) => node.id === id);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
const newNodes = nodes.filter((node) => node.id !== id);
|
||||
const newEdges = edges.flatMap((edge) => {
|
||||
if (edge.source === id) {
|
||||
return [];
|
||||
}
|
||||
if (edge.target === id) {
|
||||
const nextEdge = edges.find((edge) => edge.source === id);
|
||||
if (nextEdge) {
|
||||
// connect the old incoming edge to the next node if both of them exist
|
||||
// also take the type of the old edge for plus button edge vs default
|
||||
return [
|
||||
{
|
||||
...edge,
|
||||
type: nextEdge.type,
|
||||
target: nextEdge.target,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [edge];
|
||||
}
|
||||
return [edge];
|
||||
});
|
||||
|
||||
if (newNodes.every((node) => node.type === "nodeAdder")) {
|
||||
// No user created nodes left, so return to the empty state.
|
||||
doLayout([], []);
|
||||
return;
|
||||
}
|
||||
|
||||
doLayout(newNodes, newEdges);
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkflowParametersStateContext.Provider
|
||||
value={[parameters, setParameters]}
|
||||
>
|
||||
<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;
|
||||
<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 (dimensionChanges.length > 0) {
|
||||
doLayout(tempNodes, edges);
|
||||
}
|
||||
onNodesChange(changes);
|
||||
}}
|
||||
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={setTitle}
|
||||
parametersPanelOpen={
|
||||
workflowPanelState.active &&
|
||||
workflowPanelState.content === "parameters"
|
||||
}
|
||||
onParametersClick={() => {
|
||||
if (
|
||||
onNodesChange(changes);
|
||||
}}
|
||||
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={setTitle}
|
||||
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,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Panel>
|
||||
{workflowPanelState.active && (
|
||||
<Panel position="top-right">
|
||||
{workflowPanelState.content === "parameters" && (
|
||||
<WorkflowParametersPanel />
|
||||
)}
|
||||
{workflowPanelState.content === "nodeLibrary" && (
|
||||
onParametersClick={() => {
|
||||
if (
|
||||
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,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Panel>
|
||||
{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
|
||||
onNodeClick={(props) => {
|
||||
addNode(props);
|
||||
}}
|
||||
first
|
||||
/>
|
||||
)}
|
||||
</Panel>
|
||||
)}
|
||||
{nodes.length === 0 && (
|
||||
<Panel position="top-right">
|
||||
<WorkflowNodeLibraryPanel
|
||||
onNodeClick={(props) => {
|
||||
addNode(props);
|
||||
}}
|
||||
first
|
||||
/>
|
||||
</Panel>
|
||||
)}
|
||||
</ReactFlow>
|
||||
</Panel>
|
||||
)}
|
||||
</ReactFlow>
|
||||
</DeleteNodeCallbackContext.Provider>
|
||||
</WorkflowParametersStateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
|
||||
import type { CodeBlockNode } from "./types";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { CodeIcon, DotsHorizontalIcon } from "@radix-ui/react-icons";
|
||||
import { CodeEditor } from "@/routes/workflows/components/CodeEditor";
|
||||
import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback";
|
||||
import { CodeIcon } from "@radix-ui/react-icons";
|
||||
import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
|
||||
import { EditableNodeTitle } from "../components/EditableNodeTitle";
|
||||
import { NodeActionMenu } from "../NodeActionMenu";
|
||||
import type { CodeBlockNode } from "./types";
|
||||
|
||||
function CodeBlockNode({ id, data }: NodeProps<CodeBlockNode>) {
|
||||
const { updateNodeData } = useReactFlow();
|
||||
const deleteNodeCallback = useDeleteNodeCallback();
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -37,9 +40,11 @@ function CodeBlockNode({ id, data }: NodeProps<CodeBlockNode>) {
|
||||
<span className="text-xs text-slate-400">Code Block</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<DotsHorizontalIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<NodeActionMenu
|
||||
onDelete={() => {
|
||||
deleteNodeCallback(id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-slate-300">Code Input</Label>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { DotsHorizontalIcon, DownloadIcon } from "@radix-ui/react-icons";
|
||||
import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback";
|
||||
import { DownloadIcon } from "@radix-ui/react-icons";
|
||||
import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
|
||||
import type { DownloadNode } from "./types";
|
||||
import { EditableNodeTitle } from "../components/EditableNodeTitle";
|
||||
import { NodeActionMenu } from "../NodeActionMenu";
|
||||
import type { DownloadNode } from "./types";
|
||||
|
||||
function DownloadNode({ id, data }: NodeProps<DownloadNode>) {
|
||||
const { updateNodeData } = useReactFlow();
|
||||
const deleteNodeCallback = useDeleteNodeCallback();
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -37,9 +40,11 @@ function DownloadNode({ id, data }: NodeProps<DownloadNode>) {
|
||||
<span className="text-xs text-slate-400">Download Block</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<DotsHorizontalIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<NodeActionMenu
|
||||
onDelete={() => {
|
||||
deleteNodeCallback(id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
|
||||
import type { FileParserNode } from "./types";
|
||||
import { CursorTextIcon, DotsHorizontalIcon } from "@radix-ui/react-icons";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback";
|
||||
import { CursorTextIcon } from "@radix-ui/react-icons";
|
||||
import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
|
||||
import { EditableNodeTitle } from "../components/EditableNodeTitle";
|
||||
import { NodeActionMenu } from "../NodeActionMenu";
|
||||
import type { FileParserNode } from "./types";
|
||||
|
||||
function FileParserNode({ id, data }: NodeProps<FileParserNode>) {
|
||||
const { updateNodeData } = useReactFlow();
|
||||
const deleteNodeCallback = useDeleteNodeCallback();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Handle
|
||||
@@ -35,9 +39,11 @@ function FileParserNode({ id, data }: NodeProps<FileParserNode>) {
|
||||
<span className="text-xs text-slate-400">File Parser Block</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<DotsHorizontalIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<NodeActionMenu
|
||||
onDelete={() => {
|
||||
deleteNodeCallback(id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { DotsHorizontalIcon, UpdateIcon } from "@radix-ui/react-icons";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback";
|
||||
import { UpdateIcon } from "@radix-ui/react-icons";
|
||||
import type { Node } from "@xyflow/react";
|
||||
import {
|
||||
Handle,
|
||||
NodeProps,
|
||||
@@ -6,15 +10,15 @@ import {
|
||||
useNodes,
|
||||
useReactFlow,
|
||||
} from "@xyflow/react";
|
||||
import type { LoopNode } from "./types";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { Node } from "@xyflow/react";
|
||||
import { EditableNodeTitle } from "../components/EditableNodeTitle";
|
||||
import { NodeActionMenu } from "../NodeActionMenu";
|
||||
import type { LoopNode } from "./types";
|
||||
|
||||
function LoopNode({ id, data }: NodeProps<LoopNode>) {
|
||||
const { updateNodeData } = useReactFlow();
|
||||
const nodes = useNodes();
|
||||
const deleteNodeCallback = useDeleteNodeCallback();
|
||||
|
||||
const children = nodes.filter((node) => node.parentId === id);
|
||||
const furthestDownChild: Node | null = children.reduce(
|
||||
(acc, child) => {
|
||||
@@ -70,9 +74,11 @@ function LoopNode({ id, data }: NodeProps<LoopNode>) {
|
||||
<span className="text-xs text-slate-400">Loop Block</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<DotsHorizontalIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<NodeActionMenu
|
||||
onDelete={() => {
|
||||
deleteNodeCallback(id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-slate-300">Loop Value</Label>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { DotsHorizontalIcon } from "@radix-ui/react-icons";
|
||||
|
||||
type Props = {
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
function NodeActionMenu({ onDelete }: Props) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<DotsHorizontalIcon className="h-6 w-6 cursor-pointer" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>Block Actions</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
Delete Block
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export { NodeActionMenu };
|
||||
@@ -1,13 +1,16 @@
|
||||
import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
|
||||
import type { SendEmailNode } from "./types";
|
||||
import { DotsHorizontalIcon, EnvelopeClosedIcon } from "@radix-ui/react-icons";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback";
|
||||
import { EnvelopeClosedIcon } from "@radix-ui/react-icons";
|
||||
import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
|
||||
import { EditableNodeTitle } from "../components/EditableNodeTitle";
|
||||
import { NodeActionMenu } from "../NodeActionMenu";
|
||||
import type { SendEmailNode } from "./types";
|
||||
|
||||
function SendEmailNode({ id, data }: NodeProps<SendEmailNode>) {
|
||||
const { updateNodeData } = useReactFlow();
|
||||
const deleteNodeCallback = useDeleteNodeCallback();
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -38,9 +41,11 @@ function SendEmailNode({ id, data }: NodeProps<SendEmailNode>) {
|
||||
<span className="text-xs text-slate-400">Send Email Block</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<DotsHorizontalIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<NodeActionMenu
|
||||
onDelete={() => {
|
||||
deleteNodeCallback(id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-slate-300">Sender</Label>
|
||||
|
||||
@@ -16,22 +16,21 @@ import {
|
||||
} from "@/components/ui/popover";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { CodeEditor } from "@/routes/workflows/components/CodeEditor";
|
||||
import {
|
||||
DotsHorizontalIcon,
|
||||
ListBulletIcon,
|
||||
MixerVerticalIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import { ListBulletIcon, MixerVerticalIcon } from "@radix-ui/react-icons";
|
||||
import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
|
||||
import { useState } from "react";
|
||||
import { EditableNodeTitle } from "../components/EditableNodeTitle";
|
||||
import { NodeActionMenu } from "../NodeActionMenu";
|
||||
import { TaskNodeDisplayModeSwitch } from "./TaskNodeDisplayModeSwitch";
|
||||
import { TaskNodeParametersPanel } from "./TaskNodeParametersPanel";
|
||||
import type { TaskNode, TaskNodeDisplayMode } from "./types";
|
||||
import { EditableNodeTitle } from "../components/EditableNodeTitle";
|
||||
import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback";
|
||||
|
||||
function TaskNode({ id, data }: NodeProps<TaskNode>) {
|
||||
const { updateNodeData } = useReactFlow();
|
||||
const [displayMode, setDisplayMode] = useState<TaskNodeDisplayMode>("basic");
|
||||
const { editable } = data;
|
||||
const deleteNodeCallback = useDeleteNodeCallback();
|
||||
|
||||
const basicContent = (
|
||||
<>
|
||||
@@ -335,9 +334,11 @@ function TaskNode({ id, data }: NodeProps<TaskNode>) {
|
||||
<span className="text-xs text-slate-400">Task Block</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<DotsHorizontalIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<NodeActionMenu
|
||||
onDelete={() => {
|
||||
deleteNodeCallback(id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<TaskNodeDisplayModeSwitch
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { CursorTextIcon, DotsHorizontalIcon } from "@radix-ui/react-icons";
|
||||
import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
|
||||
import type { TextPromptNode } from "./types";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AutoResizingTextarea } from "@/components/AutoResizingTextarea/AutoResizingTextarea";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { CodeEditor } from "@/routes/workflows/components/CodeEditor";
|
||||
import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback";
|
||||
import { CursorTextIcon } from "@radix-ui/react-icons";
|
||||
import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
|
||||
import { EditableNodeTitle } from "../components/EditableNodeTitle";
|
||||
import { NodeActionMenu } from "../NodeActionMenu";
|
||||
import type { TextPromptNode } from "./types";
|
||||
|
||||
function TextPromptNode({ id, data }: NodeProps<TextPromptNode>) {
|
||||
const { updateNodeData } = useReactFlow();
|
||||
const { editable } = data;
|
||||
const deleteNodeCallback = useDeleteNodeCallback();
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -41,9 +44,11 @@ function TextPromptNode({ id, data }: NodeProps<TextPromptNode>) {
|
||||
<span className="text-xs text-slate-400">Text Prompt Block</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<DotsHorizontalIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<NodeActionMenu
|
||||
onDelete={() => {
|
||||
deleteNodeCallback(id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-slate-300">Prompt</Label>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
|
||||
import type { UploadNode } from "./types";
|
||||
import { DotsHorizontalIcon, UploadIcon } from "@radix-ui/react-icons";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback";
|
||||
import { UploadIcon } from "@radix-ui/react-icons";
|
||||
import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
|
||||
import { EditableNodeTitle } from "../components/EditableNodeTitle";
|
||||
import { NodeActionMenu } from "../NodeActionMenu";
|
||||
import type { UploadNode } from "./types";
|
||||
|
||||
function UploadNode({ id, data }: NodeProps<UploadNode>) {
|
||||
const { updateNodeData } = useReactFlow();
|
||||
const deleteNodeCallback = useDeleteNodeCallback();
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -37,9 +40,11 @@ function UploadNode({ id, data }: NodeProps<UploadNode>) {
|
||||
<span className="text-xs text-slate-400">Upload Block</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<DotsHorizontalIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<NodeActionMenu
|
||||
onDelete={() => {
|
||||
deleteNodeCallback(id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { Edge } from "@xyflow/react";
|
||||
import { AppNode } from "./nodes";
|
||||
import Dagre from "@dagrejs/dagre";
|
||||
import { Edge } from "@xyflow/react";
|
||||
import { nanoid } from "nanoid";
|
||||
import type { WorkflowBlock } from "../types/workflowTypes";
|
||||
import { nodeTypes } from "./nodes";
|
||||
import { taskNodeDefaultData } from "./nodes/TaskNode/types";
|
||||
import { LoopNode, loopNodeDefaultData } from "./nodes/LoopNode/types";
|
||||
import { BlockYAML } from "../types/workflowYamlTypes";
|
||||
import { REACT_FLOW_EDGE_Z_INDEX } from "./constants";
|
||||
import { AppNode, nodeTypes } from "./nodes";
|
||||
import { codeBlockNodeDefaultData } from "./nodes/CodeBlockNode/types";
|
||||
import { downloadNodeDefaultData } from "./nodes/DownloadNode/types";
|
||||
import { uploadNodeDefaultData } from "./nodes/UploadNode/types";
|
||||
import { sendEmailNodeDefaultData } from "./nodes/SendEmailNode/types";
|
||||
import { textPromptNodeDefaultData } from "./nodes/TextPromptNode/types";
|
||||
import { fileParserNodeDefaultData } from "./nodes/FileParserNode/types";
|
||||
import { BlockYAML } from "../types/workflowYamlTypes";
|
||||
import { LoopNode, loopNodeDefaultData } from "./nodes/LoopNode/types";
|
||||
import { NodeAdderNode } from "./nodes/NodeAdderNode/types";
|
||||
import { REACT_FLOW_EDGE_Z_INDEX } from "./constants";
|
||||
import { sendEmailNodeDefaultData } from "./nodes/SendEmailNode/types";
|
||||
import { taskNodeDefaultData } from "./nodes/TaskNode/types";
|
||||
import { textPromptNodeDefaultData } from "./nodes/TextPromptNode/types";
|
||||
import { uploadNodeDefaultData } from "./nodes/UploadNode/types";
|
||||
|
||||
export const NEW_NODE_LABEL_PREFIX = "Block ";
|
||||
|
||||
function layoutUtil(
|
||||
nodes: Array<AppNode>,
|
||||
@@ -211,38 +213,84 @@ function convertToNode(
|
||||
}
|
||||
}
|
||||
|
||||
function getElements(
|
||||
function generateNodeData(blocks: Array<WorkflowBlock>): Array<{
|
||||
id: string;
|
||||
previous: string | null;
|
||||
next: string | null;
|
||||
parentId: string | null;
|
||||
block: WorkflowBlock;
|
||||
}> {
|
||||
const idMap = new WeakMap<WorkflowBlock, string>();
|
||||
const stack = [...blocks];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const block = stack.pop()!;
|
||||
const id = nanoid();
|
||||
idMap.set(block, id);
|
||||
if (block.block_type === "for_loop") {
|
||||
stack.push(...block.loop_blocks);
|
||||
}
|
||||
}
|
||||
|
||||
return getNodeData(blocks, idMap, null);
|
||||
}
|
||||
|
||||
function getNodeData(
|
||||
blocks: Array<WorkflowBlock>,
|
||||
parentId?: string,
|
||||
): { nodes: Array<AppNode>; edges: Array<Edge> } {
|
||||
ids: WeakMap<WorkflowBlock, string>,
|
||||
parentId: string | null,
|
||||
): Array<{
|
||||
id: string;
|
||||
previous: string | null;
|
||||
next: string | null;
|
||||
parentId: string | null;
|
||||
block: WorkflowBlock;
|
||||
}> {
|
||||
const data: Array<{
|
||||
id: string;
|
||||
previous: string | null;
|
||||
next: string | null;
|
||||
parentId: string | null;
|
||||
block: WorkflowBlock;
|
||||
}> = [];
|
||||
|
||||
blocks.forEach((block, index) => {
|
||||
const id = ids.get(block)!;
|
||||
const previous = index === 0 ? null : ids.get(blocks[index - 1]!)!;
|
||||
const next =
|
||||
index === blocks.length - 1 ? null : ids.get(blocks[index + 1]!)!;
|
||||
data.push({ id, previous, next, parentId, block });
|
||||
if (block.block_type === "for_loop") {
|
||||
data.push(...getNodeData(block.loop_blocks, ids, id));
|
||||
}
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function getElements(blocks: Array<WorkflowBlock>): {
|
||||
nodes: Array<AppNode>;
|
||||
edges: Array<Edge>;
|
||||
} {
|
||||
const data = generateNodeData(blocks);
|
||||
const nodes: Array<AppNode> = [];
|
||||
const edges: Array<Edge> = [];
|
||||
|
||||
blocks.forEach((block, index) => {
|
||||
const id = parentId ? `${parentId}-${index}` : String(index);
|
||||
const nextId = parentId ? `${parentId}-${index + 1}` : String(index + 1);
|
||||
nodes.push(convertToNode({ id, parentId }, block));
|
||||
if (block.block_type === "for_loop") {
|
||||
const subElements = getElements(block.loop_blocks, id);
|
||||
if (subElements.nodes.length === 0) {
|
||||
nodes.push({
|
||||
id: `${id}-nodeAdder`,
|
||||
type: "nodeAdder",
|
||||
position: { x: 0, y: 0 },
|
||||
data: {},
|
||||
draggable: false,
|
||||
connectable: false,
|
||||
});
|
||||
}
|
||||
nodes.push(...subElements.nodes);
|
||||
edges.push(...subElements.edges);
|
||||
}
|
||||
if (index !== blocks.length - 1) {
|
||||
data.forEach((d) => {
|
||||
const node = convertToNode(
|
||||
{
|
||||
id: d.id,
|
||||
parentId: d.parentId ?? undefined,
|
||||
},
|
||||
d.block,
|
||||
);
|
||||
nodes.push(node);
|
||||
if (d.previous) {
|
||||
edges.push({
|
||||
id: `edge-${id}-${nextId}`,
|
||||
id: nanoid(),
|
||||
type: "edgeWithAddButton",
|
||||
source: id,
|
||||
target: nextId,
|
||||
source: d.previous,
|
||||
target: d.id,
|
||||
style: {
|
||||
strokeWidth: 2,
|
||||
},
|
||||
@@ -252,10 +300,11 @@ function getElements(
|
||||
});
|
||||
|
||||
if (nodes.length > 0) {
|
||||
const lastNode = data.find((d) => d.next === null && d.parentId === null);
|
||||
edges.push({
|
||||
id: "edge-nodeAdder",
|
||||
type: "default",
|
||||
source: nodes[nodes.length - 1]!.id,
|
||||
source: lastNode!.id,
|
||||
target: "nodeAdder",
|
||||
style: {
|
||||
strokeWidth: 2,
|
||||
@@ -277,9 +326,8 @@ function getElements(
|
||||
function createNode(
|
||||
identifiers: { id: string; parentId?: string },
|
||||
nodeType: Exclude<keyof typeof nodeTypes, "nodeAdder">,
|
||||
labelPostfix: string, // unique label requirement
|
||||
label: string,
|
||||
): AppNode {
|
||||
const label = "Block " + labelPostfix;
|
||||
const common = {
|
||||
draggable: false,
|
||||
position: { x: 0, y: 0 },
|
||||
@@ -496,4 +544,21 @@ function getWorkflowBlocks(nodes: Array<AppNode>): Array<BlockYAML> {
|
||||
);
|
||||
}
|
||||
|
||||
export { getElements, layout, createNode, getWorkflowBlocks };
|
||||
function generateNodeLabel(existingLabels: Array<string>) {
|
||||
for (let i = 1; i < existingLabels.length + 2; i++) {
|
||||
const label = NEW_NODE_LABEL_PREFIX + i;
|
||||
if (!existingLabels.includes(label)) {
|
||||
return label;
|
||||
}
|
||||
}
|
||||
throw new Error("Failed to generate a new node label");
|
||||
}
|
||||
|
||||
export {
|
||||
createNode,
|
||||
generateNodeData,
|
||||
getElements,
|
||||
getWorkflowBlocks,
|
||||
layout,
|
||||
generateNodeLabel,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { DeleteNodeCallbackContext } from "@/store/DeleteNodeCallbackContext";
|
||||
import { useContext } from "react";
|
||||
|
||||
function useDeleteNodeCallback() {
|
||||
const deleteNodeCallback = useContext(DeleteNodeCallbackContext);
|
||||
|
||||
if (!deleteNodeCallback) {
|
||||
throw new Error(
|
||||
"useDeleteNodeCallback must be used within a DeleteNodeCallbackProvider",
|
||||
);
|
||||
}
|
||||
|
||||
return deleteNodeCallback;
|
||||
}
|
||||
|
||||
export { useDeleteNodeCallback };
|
||||
9
skyvern-frontend/src/store/DeleteNodeCallbackContext.ts
Normal file
9
skyvern-frontend/src/store/DeleteNodeCallbackContext.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
type DeleteNodeCallback = (id: string) => void;
|
||||
|
||||
const DeleteNodeCallbackContext = createContext<DeleteNodeCallback | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
export { DeleteNodeCallbackContext };
|
||||
Reference in New Issue
Block a user