Deletable nodes (#801)

Co-authored-by: Muhammed Salih Altun <muhammedsalihaltun@gmail.com>
This commit is contained in:
Kerem Yilmaz
2024-09-10 07:07:56 -07:00
committed by GitHub
parent b12f09c535
commit 0053736f8f
15 changed files with 423 additions and 190 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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>
); );
} }

View File

@@ -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>

View File

@@ -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">

View File

@@ -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">

View File

@@ -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>

View File

@@ -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 };

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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">

View File

@@ -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,
};

View File

@@ -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 };

View File

@@ -0,0 +1,9 @@
import { createContext } from "react";
type DeleteNodeCallback = (id: string) => void;
const DeleteNodeCallbackContext = createContext<DeleteNodeCallback | undefined>(
undefined,
);
export { DeleteNodeCallbackContext };