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",
|
"embla-carousel-react": "^8.0.0",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"fetch-to-curl": "^0.6.0",
|
"fetch-to-curl": "^0.6.0",
|
||||||
|
"nanoid": "^5.0.7",
|
||||||
"open": "^10.1.0",
|
"open": "^10.1.0",
|
||||||
"posthog-js": "^1.138.0",
|
"posthog-js": "^1.138.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
@@ -5829,9 +5830,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.7",
|
"version": "5.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.7.tgz",
|
||||||
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
|
"integrity": "sha512-oLxFY2gd2IqnjcYyOXD8XGCftpGtZP2AbHbOkthDkvRywH5ayNtPVy9YlOPcHckXzbLTCHpkb7FB+yuxKV13pQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -5839,10 +5840,10 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"bin": {
|
"bin": {
|
||||||
"nanoid": "bin/nanoid.cjs"
|
"nanoid": "bin/nanoid.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
"node": "^18 || >=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/natural-compare": {
|
"node_modules/natural-compare": {
|
||||||
@@ -6299,6 +6300,23 @@
|
|||||||
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="
|
"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": {
|
"node_modules/posthog-js": {
|
||||||
"version": "1.138.0",
|
"version": "1.138.0",
|
||||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.138.0.tgz",
|
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.138.0.tgz",
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
"embla-carousel-react": "^8.0.0",
|
"embla-carousel-react": "^8.0.0",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"fetch-to-curl": "^0.6.0",
|
"fetch-to-curl": "^0.6.0",
|
||||||
|
"nanoid": "^5.0.7",
|
||||||
"open": "^10.1.0",
|
"open": "^10.1.0",
|
||||||
"posthog-js": "^1.138.0",
|
"posthog-js": "^1.138.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ import "@xyflow/react/dist/style.css";
|
|||||||
import { WorkflowHeader } from "./WorkflowHeader";
|
import { WorkflowHeader } from "./WorkflowHeader";
|
||||||
import { AppNode, nodeTypes } from "./nodes";
|
import { AppNode, nodeTypes } from "./nodes";
|
||||||
import "./reactFlowOverrideStyles.css";
|
import "./reactFlowOverrideStyles.css";
|
||||||
import { createNode, getWorkflowBlocks, layout } from "./workflowEditorUtils";
|
import {
|
||||||
|
createNode,
|
||||||
|
generateNodeLabel,
|
||||||
|
getWorkflowBlocks,
|
||||||
|
layout,
|
||||||
|
} from "./workflowEditorUtils";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { WorkflowParametersPanel } from "./panels/WorkflowParametersPanel";
|
import { WorkflowParametersPanel } from "./panels/WorkflowParametersPanel";
|
||||||
import { edgeTypes } from "./edges";
|
import { edgeTypes } from "./edges";
|
||||||
@@ -26,6 +31,8 @@ import {
|
|||||||
} from "../types/workflowYamlTypes";
|
} from "../types/workflowYamlTypes";
|
||||||
import { WorkflowParametersStateContext } from "./WorkflowParametersStateContext";
|
import { WorkflowParametersStateContext } from "./WorkflowParametersStateContext";
|
||||||
import { WorkflowParameterValueType } from "../types/workflowTypes";
|
import { WorkflowParameterValueType } from "../types/workflowTypes";
|
||||||
|
import { DeleteNodeCallbackContext } from "@/store/DeleteNodeCallbackContext";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
function convertToParametersYAML(
|
function convertToParametersYAML(
|
||||||
parameters: ParametersState,
|
parameters: ParametersState,
|
||||||
@@ -131,11 +138,12 @@ function FlowRenderer({
|
|||||||
}: AddNodeProps) {
|
}: AddNodeProps) {
|
||||||
const newNodes: Array<AppNode> = [];
|
const newNodes: Array<AppNode> = [];
|
||||||
const newEdges: Array<Edge> = [];
|
const newEdges: Array<Edge> = [];
|
||||||
const index = parent
|
const id = nanoid();
|
||||||
? nodes.filter((node) => node.parentId === parent).length
|
const node = createNode(
|
||||||
: nodes.length;
|
{ id, parentId: parent },
|
||||||
const id = parent ? `${parent}-${index}` : String(index);
|
nodeType,
|
||||||
const node = createNode({ id, parentId: parent }, nodeType, String(index));
|
generateNodeLabel(nodes.map((node) => node.data.label)),
|
||||||
|
);
|
||||||
newNodes.push(node);
|
newNodes.push(node);
|
||||||
if (previous) {
|
if (previous) {
|
||||||
const newEdge = {
|
const newEdge = {
|
||||||
@@ -163,6 +171,7 @@ function FlowRenderer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (nodeType === "loop") {
|
if (nodeType === "loop") {
|
||||||
|
// when loop node is first created it needs an adder node so nodes can be added inside the loop
|
||||||
newNodes.push({
|
newNodes.push({
|
||||||
id: `${id}-nodeAdder`,
|
id: `${id}-nodeAdder`,
|
||||||
type: "nodeAdder",
|
type: "nodeAdder",
|
||||||
@@ -183,6 +192,7 @@ function FlowRenderer({
|
|||||||
? nodes.indexOf(previousNode)
|
? nodes.indexOf(previousNode)
|
||||||
: nodes.length - 1;
|
: nodes.length - 1;
|
||||||
|
|
||||||
|
// creating some memory for no reason, maybe check it out later
|
||||||
const newNodesAfter = [
|
const newNodesAfter = [
|
||||||
...nodes.slice(0, previousNodeIndex + 1),
|
...nodes.slice(0, previousNodeIndex + 1),
|
||||||
...newNodes,
|
...newNodes,
|
||||||
@@ -190,6 +200,7 @@ function FlowRenderer({
|
|||||||
];
|
];
|
||||||
|
|
||||||
if (nodes.length === 0) {
|
if (nodes.length === 0) {
|
||||||
|
// if there were no nodes before, add a nodeAdder node and connect it to the new node
|
||||||
newNodesAfter.push({
|
newNodesAfter.push({
|
||||||
id: `${id}-nodeAdder`,
|
id: `${id}-nodeAdder`,
|
||||||
type: "nodeAdder",
|
type: "nodeAdder",
|
||||||
@@ -212,10 +223,48 @@ function FlowRenderer({
|
|||||||
doLayout(newNodesAfter, [...editedEdges, ...newEdges]);
|
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 (
|
return (
|
||||||
<WorkflowParametersStateContext.Provider
|
<WorkflowParametersStateContext.Provider
|
||||||
value={[parameters, setParameters]}
|
value={[parameters, setParameters]}
|
||||||
>
|
>
|
||||||
|
<DeleteNodeCallbackContext.Provider value={deleteNode}>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
@@ -309,6 +358,7 @@ function FlowRenderer({
|
|||||||
</Panel>
|
</Panel>
|
||||||
)}
|
)}
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
|
</DeleteNodeCallbackContext.Provider>
|
||||||
</WorkflowParametersStateContext.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 { Label } from "@/components/ui/label";
|
||||||
import { CodeIcon, DotsHorizontalIcon } from "@radix-ui/react-icons";
|
|
||||||
import { CodeEditor } from "@/routes/workflows/components/CodeEditor";
|
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 { EditableNodeTitle } from "../components/EditableNodeTitle";
|
||||||
|
import { NodeActionMenu } from "../NodeActionMenu";
|
||||||
|
import type { CodeBlockNode } from "./types";
|
||||||
|
|
||||||
function CodeBlockNode({ id, data }: NodeProps<CodeBlockNode>) {
|
function CodeBlockNode({ id, data }: NodeProps<CodeBlockNode>) {
|
||||||
const { updateNodeData } = useReactFlow();
|
const { updateNodeData } = useReactFlow();
|
||||||
|
const deleteNodeCallback = useDeleteNodeCallback();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -37,9 +40,11 @@ function CodeBlockNode({ id, data }: NodeProps<CodeBlockNode>) {
|
|||||||
<span className="text-xs text-slate-400">Code Block</span>
|
<span className="text-xs text-slate-400">Code Block</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<NodeActionMenu
|
||||||
<DotsHorizontalIcon className="h-6 w-6" />
|
onDelete={() => {
|
||||||
</div>
|
deleteNodeCallback(id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs text-slate-300">Code Input</Label>
|
<Label className="text-xs text-slate-300">Code Input</Label>
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
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 { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
|
||||||
import type { DownloadNode } from "./types";
|
|
||||||
import { EditableNodeTitle } from "../components/EditableNodeTitle";
|
import { EditableNodeTitle } from "../components/EditableNodeTitle";
|
||||||
|
import { NodeActionMenu } from "../NodeActionMenu";
|
||||||
|
import type { DownloadNode } from "./types";
|
||||||
|
|
||||||
function DownloadNode({ id, data }: NodeProps<DownloadNode>) {
|
function DownloadNode({ id, data }: NodeProps<DownloadNode>) {
|
||||||
const { updateNodeData } = useReactFlow();
|
const { updateNodeData } = useReactFlow();
|
||||||
|
const deleteNodeCallback = useDeleteNodeCallback();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -37,9 +40,11 @@ function DownloadNode({ id, data }: NodeProps<DownloadNode>) {
|
|||||||
<span className="text-xs text-slate-400">Download Block</span>
|
<span className="text-xs text-slate-400">Download Block</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<NodeActionMenu
|
||||||
<DotsHorizontalIcon className="h-6 w-6" />
|
onDelete={() => {
|
||||||
</div>
|
deleteNodeCallback(id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-1">
|
<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 { 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 { EditableNodeTitle } from "../components/EditableNodeTitle";
|
||||||
|
import { NodeActionMenu } from "../NodeActionMenu";
|
||||||
|
import type { FileParserNode } from "./types";
|
||||||
|
|
||||||
function FileParserNode({ id, data }: NodeProps<FileParserNode>) {
|
function FileParserNode({ id, data }: NodeProps<FileParserNode>) {
|
||||||
const { updateNodeData } = useReactFlow();
|
const { updateNodeData } = useReactFlow();
|
||||||
|
const deleteNodeCallback = useDeleteNodeCallback();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Handle
|
<Handle
|
||||||
@@ -35,9 +39,11 @@ function FileParserNode({ id, data }: NodeProps<FileParserNode>) {
|
|||||||
<span className="text-xs text-slate-400">File Parser Block</span>
|
<span className="text-xs text-slate-400">File Parser Block</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<NodeActionMenu
|
||||||
<DotsHorizontalIcon className="h-6 w-6" />
|
onDelete={() => {
|
||||||
</div>
|
deleteNodeCallback(id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-1">
|
<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 {
|
import {
|
||||||
Handle,
|
Handle,
|
||||||
NodeProps,
|
NodeProps,
|
||||||
@@ -6,15 +10,15 @@ import {
|
|||||||
useNodes,
|
useNodes,
|
||||||
useReactFlow,
|
useReactFlow,
|
||||||
} from "@xyflow/react";
|
} 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 { EditableNodeTitle } from "../components/EditableNodeTitle";
|
||||||
|
import { NodeActionMenu } from "../NodeActionMenu";
|
||||||
|
import type { LoopNode } from "./types";
|
||||||
|
|
||||||
function LoopNode({ id, data }: NodeProps<LoopNode>) {
|
function LoopNode({ id, data }: NodeProps<LoopNode>) {
|
||||||
const { updateNodeData } = useReactFlow();
|
const { updateNodeData } = useReactFlow();
|
||||||
const nodes = useNodes();
|
const nodes = useNodes();
|
||||||
|
const deleteNodeCallback = useDeleteNodeCallback();
|
||||||
|
|
||||||
const children = nodes.filter((node) => node.parentId === id);
|
const children = nodes.filter((node) => node.parentId === id);
|
||||||
const furthestDownChild: Node | null = children.reduce(
|
const furthestDownChild: Node | null = children.reduce(
|
||||||
(acc, child) => {
|
(acc, child) => {
|
||||||
@@ -70,9 +74,11 @@ function LoopNode({ id, data }: NodeProps<LoopNode>) {
|
|||||||
<span className="text-xs text-slate-400">Loop Block</span>
|
<span className="text-xs text-slate-400">Loop Block</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<NodeActionMenu
|
||||||
<DotsHorizontalIcon className="h-6 w-6" />
|
onDelete={() => {
|
||||||
</div>
|
deleteNodeCallback(id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs text-slate-300">Loop Value</Label>
|
<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 { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
import { Separator } from "@/components/ui/separator";
|
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 { EditableNodeTitle } from "../components/EditableNodeTitle";
|
||||||
|
import { NodeActionMenu } from "../NodeActionMenu";
|
||||||
|
import type { SendEmailNode } from "./types";
|
||||||
|
|
||||||
function SendEmailNode({ id, data }: NodeProps<SendEmailNode>) {
|
function SendEmailNode({ id, data }: NodeProps<SendEmailNode>) {
|
||||||
const { updateNodeData } = useReactFlow();
|
const { updateNodeData } = useReactFlow();
|
||||||
|
const deleteNodeCallback = useDeleteNodeCallback();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -38,9 +41,11 @@ function SendEmailNode({ id, data }: NodeProps<SendEmailNode>) {
|
|||||||
<span className="text-xs text-slate-400">Send Email Block</span>
|
<span className="text-xs text-slate-400">Send Email Block</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<NodeActionMenu
|
||||||
<DotsHorizontalIcon className="h-6 w-6" />
|
onDelete={() => {
|
||||||
</div>
|
deleteNodeCallback(id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs text-slate-300">Sender</Label>
|
<Label className="text-xs text-slate-300">Sender</Label>
|
||||||
|
|||||||
@@ -16,22 +16,21 @@ import {
|
|||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { CodeEditor } from "@/routes/workflows/components/CodeEditor";
|
import { CodeEditor } from "@/routes/workflows/components/CodeEditor";
|
||||||
import {
|
import { ListBulletIcon, MixerVerticalIcon } from "@radix-ui/react-icons";
|
||||||
DotsHorizontalIcon,
|
|
||||||
ListBulletIcon,
|
|
||||||
MixerVerticalIcon,
|
|
||||||
} from "@radix-ui/react-icons";
|
|
||||||
import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
|
import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { EditableNodeTitle } from "../components/EditableNodeTitle";
|
||||||
|
import { NodeActionMenu } from "../NodeActionMenu";
|
||||||
import { TaskNodeDisplayModeSwitch } from "./TaskNodeDisplayModeSwitch";
|
import { TaskNodeDisplayModeSwitch } from "./TaskNodeDisplayModeSwitch";
|
||||||
import { TaskNodeParametersPanel } from "./TaskNodeParametersPanel";
|
import { TaskNodeParametersPanel } from "./TaskNodeParametersPanel";
|
||||||
import type { TaskNode, TaskNodeDisplayMode } from "./types";
|
import type { TaskNode, TaskNodeDisplayMode } from "./types";
|
||||||
import { EditableNodeTitle } from "../components/EditableNodeTitle";
|
import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback";
|
||||||
|
|
||||||
function TaskNode({ id, data }: NodeProps<TaskNode>) {
|
function TaskNode({ id, data }: NodeProps<TaskNode>) {
|
||||||
const { updateNodeData } = useReactFlow();
|
const { updateNodeData } = useReactFlow();
|
||||||
const [displayMode, setDisplayMode] = useState<TaskNodeDisplayMode>("basic");
|
const [displayMode, setDisplayMode] = useState<TaskNodeDisplayMode>("basic");
|
||||||
const { editable } = data;
|
const { editable } = data;
|
||||||
|
const deleteNodeCallback = useDeleteNodeCallback();
|
||||||
|
|
||||||
const basicContent = (
|
const basicContent = (
|
||||||
<>
|
<>
|
||||||
@@ -335,9 +334,11 @@ function TaskNode({ id, data }: NodeProps<TaskNode>) {
|
|||||||
<span className="text-xs text-slate-400">Task Block</span>
|
<span className="text-xs text-slate-400">Task Block</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<NodeActionMenu
|
||||||
<DotsHorizontalIcon className="h-6 w-6" />
|
onDelete={() => {
|
||||||
</div>
|
deleteNodeCallback(id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<TaskNodeDisplayModeSwitch
|
<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 { AutoResizingTextarea } from "@/components/AutoResizingTextarea/AutoResizingTextarea";
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
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 { 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 { EditableNodeTitle } from "../components/EditableNodeTitle";
|
||||||
|
import { NodeActionMenu } from "../NodeActionMenu";
|
||||||
|
import type { TextPromptNode } from "./types";
|
||||||
|
|
||||||
function TextPromptNode({ id, data }: NodeProps<TextPromptNode>) {
|
function TextPromptNode({ id, data }: NodeProps<TextPromptNode>) {
|
||||||
const { updateNodeData } = useReactFlow();
|
const { updateNodeData } = useReactFlow();
|
||||||
const { editable } = data;
|
const { editable } = data;
|
||||||
|
const deleteNodeCallback = useDeleteNodeCallback();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -41,9 +44,11 @@ function TextPromptNode({ id, data }: NodeProps<TextPromptNode>) {
|
|||||||
<span className="text-xs text-slate-400">Text Prompt Block</span>
|
<span className="text-xs text-slate-400">Text Prompt Block</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<NodeActionMenu
|
||||||
<DotsHorizontalIcon className="h-6 w-6" />
|
onDelete={() => {
|
||||||
</div>
|
deleteNodeCallback(id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs text-slate-300">Prompt</Label>
|
<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 { 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 { EditableNodeTitle } from "../components/EditableNodeTitle";
|
||||||
|
import { NodeActionMenu } from "../NodeActionMenu";
|
||||||
|
import type { UploadNode } from "./types";
|
||||||
|
|
||||||
function UploadNode({ id, data }: NodeProps<UploadNode>) {
|
function UploadNode({ id, data }: NodeProps<UploadNode>) {
|
||||||
const { updateNodeData } = useReactFlow();
|
const { updateNodeData } = useReactFlow();
|
||||||
|
const deleteNodeCallback = useDeleteNodeCallback();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -37,9 +40,11 @@ function UploadNode({ id, data }: NodeProps<UploadNode>) {
|
|||||||
<span className="text-xs text-slate-400">Upload Block</span>
|
<span className="text-xs text-slate-400">Upload Block</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<NodeActionMenu
|
||||||
<DotsHorizontalIcon className="h-6 w-6" />
|
onDelete={() => {
|
||||||
</div>
|
deleteNodeCallback(id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
import { Edge } from "@xyflow/react";
|
|
||||||
import { AppNode } from "./nodes";
|
|
||||||
import Dagre from "@dagrejs/dagre";
|
import Dagre from "@dagrejs/dagre";
|
||||||
|
import { Edge } from "@xyflow/react";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
import type { WorkflowBlock } from "../types/workflowTypes";
|
import type { WorkflowBlock } from "../types/workflowTypes";
|
||||||
import { nodeTypes } from "./nodes";
|
import { BlockYAML } from "../types/workflowYamlTypes";
|
||||||
import { taskNodeDefaultData } from "./nodes/TaskNode/types";
|
import { REACT_FLOW_EDGE_Z_INDEX } from "./constants";
|
||||||
import { LoopNode, loopNodeDefaultData } from "./nodes/LoopNode/types";
|
import { AppNode, nodeTypes } from "./nodes";
|
||||||
import { codeBlockNodeDefaultData } from "./nodes/CodeBlockNode/types";
|
import { codeBlockNodeDefaultData } from "./nodes/CodeBlockNode/types";
|
||||||
import { downloadNodeDefaultData } from "./nodes/DownloadNode/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 { fileParserNodeDefaultData } from "./nodes/FileParserNode/types";
|
||||||
import { BlockYAML } from "../types/workflowYamlTypes";
|
import { LoopNode, loopNodeDefaultData } from "./nodes/LoopNode/types";
|
||||||
import { NodeAdderNode } from "./nodes/NodeAdderNode/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(
|
function layoutUtil(
|
||||||
nodes: Array<AppNode>,
|
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>,
|
blocks: Array<WorkflowBlock>,
|
||||||
parentId?: string,
|
ids: WeakMap<WorkflowBlock, string>,
|
||||||
): { nodes: Array<AppNode>; edges: Array<Edge> } {
|
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 nodes: Array<AppNode> = [];
|
||||||
const edges: Array<Edge> = [];
|
const edges: Array<Edge> = [];
|
||||||
|
|
||||||
blocks.forEach((block, index) => {
|
data.forEach((d) => {
|
||||||
const id = parentId ? `${parentId}-${index}` : String(index);
|
const node = convertToNode(
|
||||||
const nextId = parentId ? `${parentId}-${index + 1}` : String(index + 1);
|
{
|
||||||
nodes.push(convertToNode({ id, parentId }, block));
|
id: d.id,
|
||||||
if (block.block_type === "for_loop") {
|
parentId: d.parentId ?? undefined,
|
||||||
const subElements = getElements(block.loop_blocks, id);
|
},
|
||||||
if (subElements.nodes.length === 0) {
|
d.block,
|
||||||
nodes.push({
|
);
|
||||||
id: `${id}-nodeAdder`,
|
nodes.push(node);
|
||||||
type: "nodeAdder",
|
if (d.previous) {
|
||||||
position: { x: 0, y: 0 },
|
|
||||||
data: {},
|
|
||||||
draggable: false,
|
|
||||||
connectable: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
nodes.push(...subElements.nodes);
|
|
||||||
edges.push(...subElements.edges);
|
|
||||||
}
|
|
||||||
if (index !== blocks.length - 1) {
|
|
||||||
edges.push({
|
edges.push({
|
||||||
id: `edge-${id}-${nextId}`,
|
id: nanoid(),
|
||||||
type: "edgeWithAddButton",
|
type: "edgeWithAddButton",
|
||||||
source: id,
|
source: d.previous,
|
||||||
target: nextId,
|
target: d.id,
|
||||||
style: {
|
style: {
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
},
|
},
|
||||||
@@ -252,10 +300,11 @@ function getElements(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (nodes.length > 0) {
|
if (nodes.length > 0) {
|
||||||
|
const lastNode = data.find((d) => d.next === null && d.parentId === null);
|
||||||
edges.push({
|
edges.push({
|
||||||
id: "edge-nodeAdder",
|
id: "edge-nodeAdder",
|
||||||
type: "default",
|
type: "default",
|
||||||
source: nodes[nodes.length - 1]!.id,
|
source: lastNode!.id,
|
||||||
target: "nodeAdder",
|
target: "nodeAdder",
|
||||||
style: {
|
style: {
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
@@ -277,9 +326,8 @@ function getElements(
|
|||||||
function createNode(
|
function createNode(
|
||||||
identifiers: { id: string; parentId?: string },
|
identifiers: { id: string; parentId?: string },
|
||||||
nodeType: Exclude<keyof typeof nodeTypes, "nodeAdder">,
|
nodeType: Exclude<keyof typeof nodeTypes, "nodeAdder">,
|
||||||
labelPostfix: string, // unique label requirement
|
label: string,
|
||||||
): AppNode {
|
): AppNode {
|
||||||
const label = "Block " + labelPostfix;
|
|
||||||
const common = {
|
const common = {
|
||||||
draggable: false,
|
draggable: false,
|
||||||
position: { x: 0, y: 0 },
|
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