diff --git a/keep-ui/app/workflows/builder/BuilderChanagesTracker.tsx b/keep-ui/app/workflows/builder/BuilderChanagesTracker.tsx new file mode 100644 index 000000000..b861786be --- /dev/null +++ b/keep-ui/app/workflows/builder/BuilderChanagesTracker.tsx @@ -0,0 +1,49 @@ +import React from 'react' +import useStore from './builder-store'; +import { Button } from '@tremor/react'; +import { reConstructWorklowToDefinition } from 'utils/reactFlow'; + +export default function BuilderChanagesTracker({onDefinitionChange}:{onDefinitionChange:(def: Record) => void}) { + const { + nodes, + edges, + setEdges, + setNodes, + isLayouted, + setIsLayouted, + v2Properties, + changes, + setChanges, + lastSavedChanges, + setLastSavedChanges + } = useStore(); + const handleDiscardChanges = (e: React.MouseEvent) => { + if(!isLayouted) return; + setEdges(lastSavedChanges.edges || []); + setNodes(lastSavedChanges.nodes || []); + setChanges(0); + setIsLayouted(false); + } + + const handleSaveChanges = (e: React.MouseEvent) =>{ + e.preventDefault(); + e.stopPropagation(); + setLastSavedChanges({nodes: nodes, edges: edges}); + const value = reConstructWorklowToDefinition({nodes: nodes, edges: edges, properties: v2Properties}); + onDefinitionChange(value); + setChanges(0); + } + + return ( +
+ + +
+ ) +} diff --git a/keep-ui/app/workflows/builder/CustomEdge.tsx b/keep-ui/app/workflows/builder/CustomEdge.tsx new file mode 100644 index 000000000..45c987153 --- /dev/null +++ b/keep-ui/app/workflows/builder/CustomEdge.tsx @@ -0,0 +1,124 @@ +import React from "react"; +import { BaseEdge, EdgeLabelRenderer, getSmoothStepPath } from "@xyflow/react"; +import type { Edge, EdgeProps } from "@xyflow/react"; +import useStore from "./builder-store"; +import { PlusIcon } from "@radix-ui/react-icons"; +import { Button } from "@tremor/react"; +import '@xyflow/react/dist/style.css'; + +interface CustomEdgeProps extends EdgeProps { + label?: string; + type?: string; + data?: any; +} + +const CustomEdge: React.FC = ({ + id, + sourceX, + sourceY, + targetX, + targetY, + label, + source, + target, + data, + style, +}: CustomEdgeProps) => { + const { deleteEdges, edges, setSelectedEdge, selectedEdge } = useStore(); + + // Calculate the path and midpoint + const [edgePath, labelX, labelY] = getSmoothStepPath({ + sourceX, + sourceY, + targetX, + targetY, + borderRadius: 10, + }); + + const midpointX = (sourceX + targetX) / 2; + const midpointY = (sourceY + targetY) / 2; + + let dynamicLabel = label; + const isLayouted = !!data?.isLayouted; + let showAddButton = !source?.includes('empty') && !target?.includes('trigger_end') && source !== 'start'; + + if (!showAddButton) { + showAddButton = target?.includes('trigger_end') && source?.includes("trigger_start"); + } + + const color = dynamicLabel === "True" ? "left-0 bg-green-500" : dynamicLabel === "False" ? "bg-red-500" : "bg-orange-500"; + return ( + <> + + + + + + + + + {!!dynamicLabel && ( +
+ {dynamicLabel} +
+ )} + {showAddButton && } +
+ + ); +}; + +export default CustomEdge; diff --git a/keep-ui/app/workflows/builder/CustomNode.tsx b/keep-ui/app/workflows/builder/CustomNode.tsx new file mode 100644 index 000000000..4785a3940 --- /dev/null +++ b/keep-ui/app/workflows/builder/CustomNode.tsx @@ -0,0 +1,170 @@ +import React, { memo } from "react"; +import { Handle, Position } from "@xyflow/react"; +import NodeMenu from "./NodeMenu"; +import useStore, { FlowNode, V2Step } from "./builder-store"; +import Image from "next/image"; +import { GoPlus } from "react-icons/go"; +import { MdNotStarted } from "react-icons/md"; +import { GoSquareFill } from "react-icons/go"; +import { PiDiamondsFourFill, PiSquareLogoFill } from "react-icons/pi"; +import { BiSolidError } from "react-icons/bi"; +import { FaHandPointer } from "react-icons/fa"; +import { toast } from "react-toastify"; + + + +function IconUrlProvider(data: FlowNode["data"]) { + const { componentType, type } = data || {}; + if (type === "alert" || type === "workflow" || type === "trigger" || !type) return "/keep.png"; + return `/icons/${type + ?.replace("step-", "") + ?.replace("action-", "") + ?.replace("__end", "") + ?.replace("condition-", "")}-icon.png`; +} + +function CustomNode({ id, data }: FlowNode) { + const { selectedNode, setSelectedNode, setOpneGlobalEditor, errorNode, synced } = useStore(); + const type = data?.type + ?.replace("step-", "") + ?.replace("action-", "") + ?.replace("condition-", "") + ?.replace("__end", "") + ?.replace("trigger_", ""); + + const isEmptyNode = !!data?.type?.includes("empty"); + const specialNodeCheck = ['start', 'end', 'trigger_end', 'trigger_start'].includes(type) + + function getTriggerIcon(step: any) { + const { type } = step; + switch (type) { + case "manual": + return + case "interval": + return + } + } + + return ( + <> + {!specialNodeCheck &&
{ + e.stopPropagation(); + if(!synced){ + toast('Please save the previous step or wait while properties sync with the workflow.'); + return; + } + if (type === 'start' || type === 'end' || id?.includes('end') || id?.includes('empty')) { + if (id?.includes('empty')) { + setSelectedNode(id); + } + setOpneGlobalEditor(true); + return; + } + setSelectedNode(id); + }} + style={{ + opacity: data.isLayouted ? 1 : 0, + borderStyle: isEmptyNode ? 'dashed' : "", + borderColor: errorNode == id ? 'red' : '' + }} + > + {isEmptyNode && ( +
+ + {selectedNode === id && ( +
Go to Toolbox
+ )} +
+ )} + {errorNode === id && } + {!isEmptyNode && ( +
+ {getTriggerIcon(data)} + {!!data && !['interval', 'manual'].includes(data.type) && {data?.type}} +
+
{data?.name}
+
+ {type} +
+
+
+ +
+
+ )} + + + +
} + + {specialNodeCheck &&
{ + e.stopPropagation(); + if(!synced){ + toast('Please save the previous step or wait while properties sync with the workflow.'); + return; + } + if (type === 'start' || type === 'end' || id?.includes('end')) { + setOpneGlobalEditor(true); + return; + } + setSelectedNode(id); + }} + > +
+ {type === 'start' && } + {type === 'end' && } + {['threshold', 'assert', 'foreach'].includes(type) && +
+ {id.includes('end') ? : + {data?.type}} +
+ } + {'start' === type && } + + {'end' === type && } +
+
} + + ); +} + +export default memo(CustomNode); diff --git a/keep-ui/app/workflows/builder/NodeMenu.tsx b/keep-ui/app/workflows/builder/NodeMenu.tsx new file mode 100644 index 000000000..3b59220e7 --- /dev/null +++ b/keep-ui/app/workflows/builder/NodeMenu.tsx @@ -0,0 +1,79 @@ +import { Menu, Transition } from "@headlessui/react"; +import { Fragment } from "react"; +import { CiSquareChevDown } from "react-icons/ci"; +import { TrashIcon } from "@heroicons/react/24/outline"; +import useStore, { FlowNode } from "./builder-store"; +import { IoMdSettings } from "react-icons/io"; + +export default function NodeMenu({ data, id }: { data: FlowNode["data"], id: string }) { + const stopPropagation = (e: React.MouseEvent) => { + e.stopPropagation(); + }; + const hideMenu = data?.type?.includes("empty") || id?.includes('end') || id?.includes("start") + const { deleteNodes, setSelectedNode, setStepEditorOpenForNode } = useStore(); + + return ( + <> + {data && !hideMenu && ( + +
+ + + +
+ + +
+ + {({ active }) => ( + + )} + + + {({ active }) => ( + + )} + +
+
+
+
+ )} + + ); +} diff --git a/keep-ui/app/workflows/builder/ReactFlowBuilder.tsx b/keep-ui/app/workflows/builder/ReactFlowBuilder.tsx new file mode 100644 index 000000000..ba303a9e2 --- /dev/null +++ b/keep-ui/app/workflows/builder/ReactFlowBuilder.tsx @@ -0,0 +1,78 @@ +import React, { useEffect, useState } from "react"; +import { ReactFlow, Background, Controls, EdgeTypes as EdgeTypesType, Edge, useReactFlow } from "@xyflow/react"; +import CustomNode from "./CustomNode"; +import CustomEdge from "./CustomEdge"; +import useWorkflowInitialization from "utils/hooks/useWorkflowInitialization"; +import DragAndDropSidebar from "./ToolBox"; +import { Provider } from "app/providers/providers"; +import ReactFlowEditor from "./ReactFlowEditor"; +import "@xyflow/react/dist/style.css"; +import { ReactFlowDefinition, V2Step, Definition} from "./builder-store"; + + +const nodeTypes = { custom: CustomNode as any }; +const edgeTypes: EdgeTypesType = { "custom-edge": CustomEdge as React.ComponentType }; + +const ReactFlowBuilder = ({ + installedProviders, + toolboxConfiguration, + definition, + onDefinitionChange, + validatorConfiguration +}: { + installedProviders: Provider[] | undefined | null; + toolboxConfiguration: Record; + definition: any; + validatorConfiguration: { + step: (step: V2Step, parent?:V2Step, defnition?: ReactFlowDefinition)=>boolean; + root: (def: Definition) => boolean + }; + onDefinitionChange:(def: Definition) => void; +}) => { + + const { + nodes, + edges, + isLoading, + onEdgesChange, + onNodesChange, + onConnect, + onDragOver, + onDrop, + } = useWorkflowInitialization( + definition, + toolboxConfiguration, + ); + + return ( +
+
+ + {!isLoading && ( + + + + + )} + +
+
+ ); +}; + +export default ReactFlowBuilder; diff --git a/keep-ui/app/workflows/builder/ReactFlowEditor.tsx b/keep-ui/app/workflows/builder/ReactFlowEditor.tsx new file mode 100644 index 000000000..22a574110 --- /dev/null +++ b/keep-ui/app/workflows/builder/ReactFlowEditor.tsx @@ -0,0 +1,131 @@ +import { useState, useEffect, useRef } from "react"; +import { IoMdSettings, IoMdClose } from "react-icons/io"; +import useStore, { V2Properties, V2Step, ReactFlowDefinition, Definition } from "./builder-store"; +import { GlobalEditorV2, StepEditorV2 } from "./editors"; +import { Divider } from "@tremor/react"; +import { Provider } from "app/providers/providers"; +import { reConstructWorklowToDefinition } from "utils/reactFlow"; +import debounce from "lodash.debounce"; + +const ReactFlowEditor = ({ + providers, + validatorConfiguration, + onDefinitionChange +}: { + providers: Provider[] | undefined | null; + validatorConfiguration: { + step: (step: V2Step, parent?: V2Step, defnition?: ReactFlowDefinition) => boolean; + root: (def: Definition) => boolean; + }; + onDefinitionChange: (def: Definition) => void +}) => { + const { selectedNode, changes, v2Properties, nodes, edges, setOpneGlobalEditor, synced, setSynced } = useStore(); + const [isOpen, setIsOpen] = useState(false); + const stepEditorRef = useRef(null); + const containerRef = useRef(null); + const isTrigger = ['interval', 'manual', 'alert'].includes(selectedNode || '') + + useEffect(() => { + setIsOpen(true); + if (selectedNode) { + const timer = setTimeout(() => { + if(isTrigger){ + setOpneGlobalEditor(true); + return; + } + if (containerRef.current && stepEditorRef.current) { + const containerRect = containerRef.current.getBoundingClientRect(); + const stepEditorRect = stepEditorRef.current.getBoundingClientRect(); + // Check if StepEditorV2 is already at the top of the container + const isAtTop = stepEditorRect.top <= containerRect.top; + + if (!isAtTop) { + // Scroll the StepEditorV2 into view + stepEditorRef.current.scrollIntoView({ behavior: "smooth", block: "start" }); + } + } + }, 100); + return () => clearTimeout(timer); // Cleanup the timer on unmount + } + }, [selectedNode]); + + useEffect(() => { + setSynced(false); + + const handleDefinitionChange = () => { + if (changes > 0) { + let { sequence, properties } = + reConstructWorklowToDefinition({ + nodes: nodes, + edges: edges, + properties: v2Properties, + }) || {}; + sequence = (sequence || []) as V2Step[]; + properties = (properties || {}) as V2Properties; + let isValid = true; + for (let step of sequence) { + isValid = validatorConfiguration?.step(step); + if (!isValid) { + break; + } + } + + if (!isValid) { + onDefinitionChange({ sequence, properties, isValid }); + setSynced(true); + return; + } + + isValid = validatorConfiguration.root({ sequence, properties }); + onDefinitionChange({ sequence, properties, isValid }); + setSynced(true); + } + setSynced(true); + }; + + const debouncedHandleDefinitionChange = debounce(handleDefinitionChange, 300); + + debouncedHandleDefinitionChange(); + + return () => { + debouncedHandleDefinitionChange.cancel(); + }; + }, [changes]); + + return ( +
+ {!isOpen && ( + + )} + {isOpen && ( +
+ +
+
+ + {!selectedNode?.includes('empty') && !isTrigger && } + {!selectedNode?.includes('empty') && !isTrigger && } +
+
+
+ )} +
+ ); +}; + +export default ReactFlowEditor; + diff --git a/keep-ui/app/workflows/builder/ToolBox.tsx b/keep-ui/app/workflows/builder/ToolBox.tsx new file mode 100644 index 000000000..2f77cb8b3 --- /dev/null +++ b/keep-ui/app/workflows/builder/ToolBox.tsx @@ -0,0 +1,195 @@ +import React, { useState, useEffect, useMemo } from "react"; +import classNames from "classnames"; +import { Disclosure } from "@headlessui/react"; +import { Subtitle } from "@tremor/react"; +import { IoChevronUp, IoClose } from "react-icons/io5"; +import Image from "next/image"; +import { IoIosArrowDown } from "react-icons/io"; +import useStore, { V2Step } from "./builder-store"; +import { FaHandPointer } from "react-icons/fa"; +import { PiDiamondsFourFill } from "react-icons/pi"; + +const GroupedMenu = ({ name, steps, searchTerm, isDraggable = true }: { + name: string, + steps: any[], + searchTerm: string; + isDraggable?: boolean; +}) => { + const [isOpen, setIsOpen] = useState(!!searchTerm || isDraggable); + const { selectedNode, selectedEdge, addNodeBetween } = useStore(); + + useEffect(() => { + setIsOpen(!!searchTerm || !isDraggable); + }, [searchTerm, isDraggable]); + + const handleAddNode = (e: React.MouseEvent, step: V2Step) => { + e.stopPropagation(); + e.preventDefault(); + if (isDraggable) { + return; + } + addNodeBetween(selectedNode || selectedEdge, step, selectedNode ? 'node' : 'edge'); + } + + function IconUrlProvider(data: any) { + const { type } = data || {}; + if (type === "alert" || type === "workflow") return "/keep.png"; + return `/icons/${type + ?.replace("step-", "") + ?.replace("action-", "") + ?.replace("condition-", "")}-icon.png`; + } + + + function getTriggerIcon(step: any) { + const { type } = step; + switch (type) { + case "manual": + return + case "interval": + return + } + } + + const handleDragStart = (event: React.DragEvent, step: any) => { + if (!isDraggable) { + event.stopPropagation(); + event.preventDefault(); + } + event.dataTransfer.setData("application/reactflow", JSON.stringify(step)); + event.dataTransfer.effectAllowed = "move"; + }; + + return ( + + {({ open }) => ( + <> + + + {name} + + + + {(open || !isDraggable) && ( + + {steps.length > 0 && + steps.map((step: any) => ( +
  • handleDragStart(event, { ...step })} + draggable={isDraggable} + title={step.name} + onClick={(e) => handleAddNode(e, step)} + > + {getTriggerIcon(step)} + {!!step && !['interval', 'manual'].includes(step.type) && {step?.type}} + {step.name} +
  • + ))} +
    + )} + + )} +
    + ); +}; + +const DragAndDropSidebar = ({ isDraggable }: { + isDraggable?: boolean; +}) => { + const [searchTerm, setSearchTerm] = useState(""); + const [isVisible, setIsVisible] = useState(false); + const [open, setOpen] = useState(false); + const { toolboxConfiguration, selectedNode, selectedEdge, nodes } = useStore(); + + useEffect(() => { + + setOpen( + !!selectedNode && selectedNode.includes('empty') || + !!selectedEdge + ) + setIsVisible(!isDraggable) + }, [selectedNode, selectedEdge, isDraggable]); + + const triggerNodeMap = nodes.filter((node: any) => ['interval', 'manual', 'alert'].includes(node?.id)).reduce((obj: any, node: any) => { + obj[node.id] = true; + return obj; + }, {} as Record); + + + const filteredGroups = useMemo(() => { + return toolboxConfiguration?.groups?.map((group: any) => ({ + ...group, + steps: group?.steps?.filter((step: any) => + step?.name?.toLowerCase().includes(searchTerm?.toLowerCase()) && !triggerNodeMap[step?.id] + ), + })) || []; + }, [toolboxConfiguration, searchTerm, nodes?.length]); + + const checkForSearchResults = searchTerm && !!filteredGroups?.find((group: any) => group?.steps?.length > 0); + + if (!open) { + return null; + } + + return ( +
    +
    + {/* Sticky header */} +
    +

    Toolbox

    +
    + setSearchTerm(e.target.value)} + /> + +
    +
    + + {/* Scrollable list */} + {(isVisible || checkForSearchResults) &&
    + {filteredGroups.length > 0 && + filteredGroups.map((group: Record) => ( + + ))} +
    } +
    +
    + + ) +}; + +export default DragAndDropSidebar; diff --git a/keep-ui/app/workflows/builder/alert.tsx b/keep-ui/app/workflows/builder/alert.tsx index d56a17b0e..0e6d2fafc 100644 --- a/keep-ui/app/workflows/builder/alert.tsx +++ b/keep-ui/app/workflows/builder/alert.tsx @@ -1,3 +1,5 @@ +import { V2Step } from "./builder-store"; + interface Provider { type: string; config: string; @@ -37,4 +39,6 @@ export interface Alert { services?: string[]; steps: Step[]; actions?: Action[]; + triggers?: any; + name?: string; } diff --git a/keep-ui/app/workflows/builder/builder-card.tsx b/keep-ui/app/workflows/builder/builder-card.tsx index b1bf5310c..8a1b1ca1b 100644 --- a/keep-ui/app/workflows/builder/builder-card.tsx +++ b/keep-ui/app/workflows/builder/builder-card.tsx @@ -71,7 +71,7 @@ export function BuilderCard({ ); return ( - + {error ? ( ; + +export type Definition = { + sequence: V2Step[]; + properties: V2Properties; + isValid?: boolean; +} + + + +export type ReactFlowDefinition = { + value: { + sequence: V2Step[], + properties: V2Properties + }, + isValid?: boolean +} + +export type V2Step = { + id: string; + name?: string; + componentType: string; + type: string; + properties: V2Properties; + branches?: { + true: V2Step[]; + false: V2Step[]; + }; + sequence?: V2Step[]; + edgeNotNeeded?: boolean; + edgeLabel?: string; + edgeColor?: string; + edgeSource?: string; + edgeTarget?: string; +}; + +export type NodeData = Node["data"] & Record; + +export type NodeStepMeta = { id: string, label?: string }; +export type FlowNode = Node & { + prevStepId?: string | string[]; + edge_label?: string; + data: NodeData; + isDraggable?: boolean; + nextStepId?: string | string[]; + prevStep?: NodeStepMeta[] | NodeStepMeta | null; + nextStep?: NodeStepMeta[] | NodeStepMeta | null; + prevNodeId?: string | null; + nextNodeId?: string | null; + id: string; + isNested: boolean; +}; + +const initialNodes: Partial[] = [ + { + id: "a", + position: { x: 0, y: 0 }, + data: { label: "Node A", type: "custom" }, + type: "custom", + }, + { + id: "b", + position: { x: 0, y: 100 }, + data: { label: "Node B", type: "custom" }, + type: "custom", + }, + { + id: "c", + position: { x: 0, y: 200 }, + data: { label: "Node C", type: "custom" }, + type: "custom", + }, +]; + +const initialEdges: Edge[] = [ + { id: "a->b", type: "custom-edge", source: "a", target: "b" }, + { id: "b->c", type: "custom-edge", source: "b", target: "c" }, +]; + + + +export type FlowState = { + nodes: FlowNode[]; + edges: Edge[]; + selectedNode: string | null; + v2Properties: V2Properties; + openGlobalEditor: boolean; + stepEditorOpenForNode: string | null; + toolboxConfiguration: Record; + onNodesChange: OnNodesChange; + onEdgesChange: OnEdgesChange; + onConnect: OnConnect; + onDragOver: (event: React.DragEvent) => void; + onDrop: ( + event: React.DragEvent, + screenToFlowPosition: (coords: { x: number; y: number }) => { + x: number; + y: number; + } + ) => void; + setNodes: (nodes: FlowNode[]) => void; + setEdges: (edges: Edge[]) => void; + getNodeById: (id: string | null) => FlowNode | undefined; + hasNode: (id: string) => boolean; + deleteEdges: (ids: string | string[]) => void; + deleteNodes: (ids: string | string[]) => void; + updateNode: (node: FlowNode) => void; + duplicateNode: (node: FlowNode) => void; + // addNode: (node: Partial) => void; + setSelectedNode: (id: string | null) => void; + setV2Properties: (properties: V2Properties) => void; + setOpneGlobalEditor: (open: boolean) => void; + // updateNodeData: (nodeId: string, key: string, value: any) => void; + updateSelectedNodeData: (key: string, value: any) => void; + updateV2Properties: (properties: V2Properties) => void; + setStepEditorOpenForNode: (nodeId: string | null) => void; + updateEdge: (id: string, key: string, value: any) => void; + setToolBoxConfig: (config: Record) => void; + addNodeBetween: (nodeOrEdge: string | null, step: V2Step, type: string) => void; + isLayouted: boolean; + setIsLayouted: (isLayouted: boolean) => void; + selectedEdge: string | null; + setSelectedEdge: (id: string | null) => void; + getEdgeById: (id: string) => Edge | undefined; + changes: number; + setChanges: (changes: number) => void; + firstInitilisationDone: boolean; + setFirstInitilisationDone: (firstInitilisationDone: boolean) => void; + lastSavedChanges: { nodes: FlowNode[] | null, edges: Edge[] | null }; + setLastSavedChanges: ({ nodes, edges }: { nodes: FlowNode[], edges: Edge[] }) => void; + setErrorNode: (id: string | null) => void; + errorNode: string | null; + synced: boolean; + setSynced: (synced: boolean) => void; +}; + + +export type StoreGet = () => FlowState +export type StoreSet = (state: FlowState | Partial | ((state: FlowState) => FlowState | Partial)) => void + +function addNodeBetween(nodeOrEdge: string | null, step: V2Step, type: string, set: StoreSet, get: StoreGet) { + if (!nodeOrEdge || !step) return; + let edge = {} as Edge; + if (type === 'node') { + edge = get().edges.find((edge) => edge.target === nodeOrEdge) as Edge + } + + if (type === 'edge') { + edge = get().edges.find((edge) => edge.id === nodeOrEdge) as Edge; + } + + let { source: sourceId, target: targetId } = edge || {}; + if (!sourceId || !targetId) return; + + const isTriggerComponent = step.componentType === 'trigger' + + if (sourceId !== 'trigger_start' && isTriggerComponent) { + return; + } + + if (sourceId == 'trigger_start' && !isTriggerComponent) { + return; + } + + const nodes = get().nodes; + if (sourceId === 'trigger_start' && isTriggerComponent && nodes.find(node => node && step.id === node.id)) { + return; + } + + let targetIndex = nodes.findIndex(node => node.id === targetId); + const sourceIndex = nodes.findIndex(node => node.id === sourceId); + if (targetIndex == -1) { + return; + } + + if (sourceId === 'trigger_start') { + targetId = 'trigger_end'; + } + const newNodeId = isTriggerComponent ? step.id : uuidv4(); + const cloneStep = JSON.parse(JSON.stringify(step)); + const newStep = { ...cloneStep, id: newNodeId } + const edges = get().edges; + + let { nodes: newNodes, edges: newEdges } = processWorkflowV2([ + { + id: sourceId, type: 'temp_node', name: 'temp_node', 'componentType': 'temp_node', + edgeLabel: edge.label, edgeColor: edge?.style?.stroke + }, + newStep, + { id: targetId, type: 'temp_node', name: 'temp_node', 'componentType': 'temp_node', edgeNotNeeded: true } + ] as V2Step[], { x: 0, y: 0 }, true); + + + const finalEdges = [ + ...newEdges, + ...(edges.filter(edge => !(edge.source == sourceId && edge.target == targetId)) || []), + ]; + + const isNested = !!(nodes[targetIndex]?.isNested || nodes[sourceIndex]?.isNested); + newNodes = newNodes.map((node) => ({ ...node, isNested })); + newNodes = [...nodes.slice(0, targetIndex), ...newNodes, ...nodes.slice(targetIndex)]; + set({ + edges: finalEdges, + nodes: newNodes, + isLayouted: false, + changes: get().changes + 1 + }); + if (type == 'edge') { + set({ + selectedEdge: edges[edges.length - 1]?.id, + }); + } + + if (type === 'node') { + set({ selectedNode: nodeOrEdge }); + } + + switch(newNodeId){ + case "interval": + case "manual": { + set({v2Properties: {...get().v2Properties, [newNodeId]: ""}}); + break; + } + case "alert": { + set({v2Properties: {...get().v2Properties, [newNodeId]: {}}}); + break; + } + } +} + +const useStore = create((set, get) => ({ + nodes: [], + edges: [], + selectedNode: null, + v2Properties: {}, + openGlobalEditor: true, + stepEditorOpenForNode: null, + toolboxConfiguration: {} as Record, + isLayouted: false, + selectedEdge: null, + changes: 0, + lastSavedChanges: { nodes: [], edges: [] }, + firstInitilisationDone: false, + errorNode: null, + synced: true, + setSynced: (sync) => set({ synced: sync }), + setErrorNode: (id) => set({ errorNode: id }), + setFirstInitilisationDone: (firstInitilisationDone) => set({ firstInitilisationDone }), + setLastSavedChanges: ({ nodes, edges }: { nodes: FlowNode[], edges: Edge[] }) => set({ lastSavedChanges: { nodes, edges } }), + setSelectedEdge: (id) => set({ selectedEdge: id, selectedNode: null, openGlobalEditor: true }), + setChanges: (changes: number) => set({ changes: changes }), + setIsLayouted: (isLayouted) => set({ isLayouted }), + getEdgeById: (id) => get().edges.find((edge) => edge.id === id), + addNodeBetween: (nodeOrEdge: string | null, step: any, type: string) => { + addNodeBetween(nodeOrEdge, step, type, set, get); + }, + setToolBoxConfig: (config) => set({ toolboxConfiguration: config }), + setOpneGlobalEditor: (open) => set({ openGlobalEditor: open }), + updateSelectedNodeData: (key, value) => { + const currentSelectedNode = get().selectedNode; + if (currentSelectedNode) { + const updatedNodes = get().nodes.map((node) => { + if (node.id === currentSelectedNode) { + //properties changes should not reconstructed the defintion. only recontrreconstructing if there are any structural changes are done on the flow. + if (value) { + node.data[key] = value; + } + if (!value) { + delete node.data[key]; + } + return { ...node } + } + return node; + }); + set({ + nodes: updatedNodes, + changes: get().changes + 1 + }); + } + }, + setV2Properties: (properties) => set({ v2Properties: properties }), + updateV2Properties: (properties) => { + const updatedProperties = { ...get().v2Properties, ...properties }; + set({ v2Properties: updatedProperties, changes: get().changes + 1 }); + }, + setSelectedNode: (id) => { + set({ + selectedNode: id || null, + openGlobalEditor: false, + selectedEdge: null + }); + }, + setStepEditorOpenForNode: (nodeId) => { + set({ openGlobalEditor: false }); + set({ stepEditorOpenForNode: nodeId }); + }, + onNodesChange: (changes) => + set({ nodes: applyNodeChanges(changes, get().nodes) }), + onEdgesChange: (changes) => + set({ edges: applyEdgeChanges(changes, get().edges) }), + onConnect: (connection) => { + const { source, target } = connection; + const sourceNode = get().getNodeById(source); + const targetNode = get().getNodeById(target); + + // Define the connection restrictions + const canConnect = (sourceNode: FlowNode | undefined, targetNode: FlowNode | undefined) => { + if (!sourceNode || !targetNode) return false; + + const sourceType = sourceNode?.data?.componentType; + const targetType = targetNode?.data?.componentType; + + // Restriction logic based on node types + if (sourceType === 'switch') { + return get().edges.filter(edge => edge.source === source).length < 2; + } + if (sourceType === 'foreach' || sourceNode?.data?.type === 'foreach') { + return true; + } + return (get().edges.filter(edge => edge.source === source).length === 0); + }; + + // Check if the connection is allowed + if (canConnect(sourceNode, targetNode)) { + const edge = { ...connection, type: "custom-edge" }; + set({ edges: addEdge(edge, get().edges) }); + set({ + nodes: get().nodes.map(node => { + if (node.id === target) { + return { ...node, prevStepId: source, isDraggable: false }; + } + if (node.id === source) { + return { ...node, isDraggable: false }; + } + return node; + }) + }); + } else { + console.warn('Connection not allowed based on node types'); + } + }, + + onDragOver: (event) => { + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + }, + onDrop: (event, screenToFlowPosition) => { + event.preventDefault(); + event.stopPropagation(); + + try { + let step: any = event.dataTransfer.getData("application/reactflow"); + if (!step) { + return; + } + step = JSON.parse(step); + if (!step) return; + // Use the screenToFlowPosition function to get flow coordinates + const position = screenToFlowPosition({ + x: event.clientX, + y: event.clientY, + }); + const newUuid = uuidv4(); + const newNode = { + id: newUuid, + type: "custom", + position, // Use the position object with x and y + data: { + label: step.name! as string, + ...step, + id: newUuid, + name: step.name, + type: step.type, + componentType: step.componentType + }, + isDraggable: true, + dragHandle: '.custom-drag-handle', + } as FlowNode; + + set({ nodes: [...get().nodes, newNode] }); + } catch (err) { + console.error(err); + } + }, + setNodes: (nodes) => set({ nodes }), + setEdges: (edges) => set({ edges }), + hasNode: (id) => !!get().nodes.find((node) => node.id === id), + getNodeById: (id) => get().nodes.find((node) => node.id === id), + deleteEdges: (ids) => { + const idArray = Array.isArray(ids) ? ids : [ids]; + set({ edges: get().edges.filter((edge) => !idArray.includes(edge.id)) }); + }, + deleteNodes: (ids) => { + //for now handling only single node deletion. can later enhance to multiple deletions + if (typeof ids !== 'string') { + return; + } + const nodes = get().nodes + const nodeStartIndex = nodes.findIndex((node) => ids == node.id); + if (nodeStartIndex === -1) { + return; + } + let idArray = Array.isArray(ids) ? ids : [ids]; + + const startNode = nodes[nodeStartIndex]; + const customIdentifier = `${startNode?.data?.type}__end__${startNode?.id}`; + + let endIndex = nodes.findIndex((node) => node.id === customIdentifier); + endIndex = endIndex === -1 ? nodeStartIndex : endIndex; + + const endNode = nodes[endIndex]; + + let edges = get().edges; + let finalEdges = edges; + idArray = nodes.slice(nodeStartIndex, endIndex + 1).map((node) => node.id); + + + finalEdges = edges.filter((edge) => !(idArray.includes(edge.source) || idArray.includes(edge.target))); + if (['interval', 'alert', 'manual'].includes(ids) && edges.some((edge) => edge.source === 'trigger_start' && edge.target !== ids)) { + edges = edges.filter((edge) => !(idArray.includes(edge.source))); + } + const sources = [...new Set(edges.filter((edge) => startNode.id === edge.target))]; + const targets = [...new Set(edges.filter((edge) => endNode.id === edge.source))]; + targets.forEach((edge) => { + const target = edge.source === 'trigger_start' ? 'triggger_end' : edge.target; + + finalEdges = [...finalEdges, ...sources.map((source: Edge) => createCustomEdgeMeta(source.source, target, source.label as string) + ).flat(1)]; + }); + // } + + + + nodes[endIndex + 1].position = { x: 0, y: 0 }; + + const newNode = createDefaultNodeV2({ ...nodes[endIndex + 1].data, islayouted: false }, nodes[endIndex + 1].id); + + const newNodes = [...nodes.slice(0, nodeStartIndex), newNode, ...nodes.slice(endIndex + 2)]; + if(['manual', 'alert', 'interval'].includes(ids)) { + const v2Properties = get().v2Properties; + delete v2Properties[ids]; + set({ v2Properties }); + } + set({ + edges: finalEdges, + nodes: newNodes, + selectedNode: null, + isLayouted: false, + changes: get().changes + 1, + openGlobalEditor: true, + }); + + }, + updateEdge: (id: string, key: string, value: any) => { + const edge = get().edges.find((e) => e.id === id); + if (!edge) return; + const newEdge = { ...edge, [key]: value }; + set({ edges: get().edges.map((e) => (e.id === edge.id ? newEdge : e)) }); + }, + updateNode: (node) => + set({ nodes: get().nodes.map((n) => (n.id === node.id ? node : n)) }), + duplicateNode: (node) => { + const { data, position } = node; + const newUuid = uuidv4(); + const newNode: FlowNode = { + ...node, + data: { + ...data, id: newUuid, + }, + isDraggable: true, + id: newUuid, + position: { x: position.x + 100, y: position.y + 100 }, + dragHandle: '.custom-drag-handle' + }; + set({ nodes: [...get().nodes, newNode] }); + }, +})); + +export default useStore; diff --git a/keep-ui/app/workflows/builder/builder-validators.tsx b/keep-ui/app/workflows/builder/builder-validators.tsx index ab21ace19..e1e131ac2 100644 --- a/keep-ui/app/workflows/builder/builder-validators.tsx +++ b/keep-ui/app/workflows/builder/builder-validators.tsx @@ -1,80 +1,120 @@ import { Dispatch, SetStateAction } from "react"; -import { - Definition, - SequentialStep, - Step, - BranchedStep, - Sequence, -} from "sequential-workflow-designer"; +import { ReactFlowDefinition, V2Step, Definition as FlowDefinition } from "./builder-store"; -export function globalValidator( - definition: Definition, - setGlobalValidationError: Dispatch> +export function globalValidatorV2( + definition: FlowDefinition, + setGlobalValidationError: (id:string|null, error:string|null)=>void, ): boolean { + const workflowName = definition?.properties?.name; + const workflowDescription = definition?.properties?.description; + if(!workflowName) { + setGlobalValidationError(null, "Workflow name cannot be empty."); + return false; + } + if(!workflowDescription){ + setGlobalValidationError(null, "Workflow description cannot be empty."); + return false; + } + + if ( + !!definition?.properties && + !('manual' in definition.properties) && + !('interval' in definition.properties) && + !('alert' in definition.properties) + ) { + setGlobalValidationError('trigger_start', "Workflow Should alteast have one trigger."); + return false; + + } + + if(definition?.properties && 'interval' in definition.properties && !definition.properties.interval){ + setGlobalValidationError('interval', "Workflow interval cannot be empty."); + return false; + } + + const alertSources = Object.values(definition.properties.alert||{}).filter(Boolean) + if(definition?.properties && 'alert' in definition.properties && alertSources.length==0){ + setGlobalValidationError('alert', "Workflow alert trigger cannot be empty."); + return false; + } + const anyStepOrAction = definition?.sequence?.length > 0; if (!anyStepOrAction) { - setGlobalValidationError( + setGlobalValidationError(null, "At least 1 step/action is required." ); } const anyActionsInMainSequence = ( - definition.sequence[0] as SequentialStep - )?.sequence?.some((step) => step.type.includes("action-")); + definition.sequence[0] as V2Step + )?.sequence?.some((step) => step?.type?.includes("action-")); if (anyActionsInMainSequence) { // This checks to see if there's any steps after the first action const actionIndex = ( - definition.sequence[0] as SequentialStep - )?.sequence.findIndex((step) => step.type.includes("action-")); - for ( - let i = actionIndex + 1; - i < (definition.sequence[0] as SequentialStep)?.sequence.length; - i++ - ) { - if ( - (definition.sequence[0] as SequentialStep)?.sequence[i].type.includes( - "step-" - ) + definition?.sequence?.[0] as V2Step + )?.sequence?.findIndex((step) => step.type.includes("action-")); + if(actionIndex && definition?.sequence){ + const sequence = definition?.sequence?.[0]?.sequence || []; + for ( + let i = actionIndex + 1; + i < sequence.length; + i++ ) { - setGlobalValidationError("Steps cannot be placed after actions."); - return false; + if ( + sequence[i]?.type?.includes( + "step-" + ) + ) { + setGlobalValidationError(sequence[i].id,"Steps cannot be placed after actions."); + return false; + } } } } const valid = anyStepOrAction; - if (valid) setGlobalValidationError(null); + if (valid) setGlobalValidationError(null, ""); return valid; } -export function stepValidator( - step: Step | BranchedStep, - parentSequence: Sequence, - definition: Definition, - setStepValidationError: Dispatch> +export function stepValidatorV2( + step: V2Step, + setStepValidationError: (step:V2Step, error:string|null)=>void, + parentSequence?: V2Step, + definition?: ReactFlowDefinition, ): boolean { if (step.type.includes("condition-")) { - const onlyActions = (step as BranchedStep).branches.true.every((step) => + if(!step.name) { + setStepValidationError(step, "Step/action name cannot be empty."); + return false; + } + const branches = (step?.branches || {true:[], false:[]}) as V2Step['branches']; + const onlyActions = branches?.true?.every((step:V2Step) => step.type.includes("action-") ); if (!onlyActions) { - setStepValidationError("Conditions can only contain actions."); + setStepValidationError(step, "Conditions can only contain actions."); return false; } - const conditionHasActions = (step as BranchedStep).branches.true.length > 0; + const conditionHasActions = branches?.true ? branches?.true.length > 0 : false; if (!conditionHasActions) - setStepValidationError("Conditions must contain at least one action."); + setStepValidationError(step, "Conditions must contain at least one action."); const valid = conditionHasActions && onlyActions; - if (valid) setStepValidationError(null); + if (valid) setStepValidationError(step, null); return valid; } - if (step.componentType === "task") { - const valid = step.name !== ""; - if (!valid) setStepValidationError("Step name cannot be empty."); - if (!step.properties.with) - setStepValidationError( + if (step?.componentType === "task") { + const valid = step?.name !== ""; + if (!valid) setStepValidationError(step, "Step name cannot be empty."); + if (!step?.properties?.with){ + setStepValidationError(step, "There is step/action with no parameters configured!" ); - if (valid && step.properties.with) setStepValidationError(null); + return false; + } + if (valid && step?.properties?.with) { + setStepValidationError(step, null); + } return valid; } + setStepValidationError(step, null); return true; } diff --git a/keep-ui/app/workflows/builder/builder.tsx b/keep-ui/app/workflows/builder/builder.tsx index 7d74ba12a..b2c7565e6 100644 --- a/keep-ui/app/workflows/builder/builder.tsx +++ b/keep-ui/app/workflows/builder/builder.tsx @@ -1,20 +1,5 @@ -import "sequential-workflow-designer/css/designer.css"; -import "sequential-workflow-designer/css/designer-light.css"; -import "sequential-workflow-designer/css/designer-dark.css"; import "./page.css"; -import { - Definition, - StepsConfiguration, - ValidatorConfiguration, - Step, - Sequence, -} from "sequential-workflow-designer"; -import { - SequentialWorkflowDesigner, - wrapDefinition, -} from "sequential-workflow-designer-react"; import { useEffect, useState } from "react"; -import StepEditor, { GlobalEditor } from "./editors"; import { Callout, Card } from "@tremor/react"; import { Provider } from "../../providers/providers"; import { @@ -22,12 +7,13 @@ import { generateWorkflow, getToolboxConfiguration, buildAlert, + wrapDefinitionV2, } from "./utils"; import { CheckCircleIcon, ExclamationCircleIcon, } from "@heroicons/react/20/solid"; -import { globalValidator, stepValidator } from "./builder-validators"; +import { globalValidatorV2, stepValidatorV2 } from "./builder-validators"; import Modal from "react-modal"; import { Alert } from "./alert"; import BuilderModalContent from "./builder-modal"; @@ -38,6 +24,9 @@ import { useSearchParams } from "next/navigation"; import { v4 as uuidv4 } from "uuid"; import BuilderWorkflowTestRunModalContent from "./builder-workflow-testrun-modal"; import { WorkflowExecution, WorkflowExecutionFailure } from "./types"; +import ReactFlowBuilder from "./ReactFlowBuilder"; +import { ReactFlowProvider } from "@xyflow/react"; +import useStore, { ReactFlowDefinition, V2Step, Definition as FlowDefinition } from "./builder-store"; interface Props { loadedAlertFile: string | null; @@ -51,7 +40,7 @@ interface Props { workflowId?: string; accessToken?: string; installedProviders?: Provider[] | undefined | null; - isPreview?:boolean; + isPreview?: boolean; } function Builder({ @@ -68,8 +57,9 @@ function Builder({ installedProviders, isPreview, }: Props) { + const [definition, setDefinition] = useState(() => - wrapDefinition({ sequence: [], properties: {} } as Definition) + wrapDefinitionV2({ sequence: [], properties: {}, isValid: false }) ); const [isLoading, setIsLoading] = useState(true); const [stepValidationError, setStepValidationError] = useState( @@ -86,6 +76,23 @@ function Builder({ const [compiledAlert, setCompiledAlert] = useState(null); const searchParams = useSearchParams(); + const { setErrorNode } = useStore(); + + const setStepValidationErrorV2 = (step: V2Step, error: string | null) => { + setStepValidationError(error); + if (error && step) { + return setErrorNode(step.id) + } + setErrorNode(null); + } + + const setGlobalValidationErrorV2 = (id:string|null, error: string | null) => { + setGlobalValidationError(error); + if (error && id) { + return setErrorNode(id) + } + setErrorNode(null); + } const updateWorkflow = () => { const apiUrl = getApiURL(); @@ -169,7 +176,7 @@ function Builder({ useEffect(() => { setIsLoading(true); if (workflow) { - setDefinition(wrapDefinition(parseWorkflow(workflow, providers))); + setDefinition(wrapDefinitionV2({...parseWorkflow(workflow, providers), isValid:true})); } else if (loadedAlertFile == null) { const alertUuid = uuidv4(); const alertName = searchParams?.get("alertName"); @@ -179,10 +186,10 @@ function Builder({ triggers = { alert: { source: alertSource, name: alertName } }; } setDefinition( - wrapDefinition(generateWorkflow(alertUuid, "", "", [], [], triggers)) + wrapDefinitionV2({...generateWorkflow(alertUuid, "", "", [], [], triggers), isValid: true}) ); } else { - setDefinition(wrapDefinition(parseWorkflow(loadedAlertFile!, providers))); + setDefinition(wrapDefinitionV2({...parseWorkflow(loadedAlertFile!, providers), isValid:true})); } setIsLoading(false); }, [loadedAlertFile, workflow, searchParams]); @@ -218,7 +225,7 @@ function Builder({ (definition.isValid && stepValidationError === null && globalValidationError === null) || - false + false ); }, [ stepValidationError, @@ -235,43 +242,17 @@ function Builder({ ); } - function IconUrlProvider(componentType: string, type: string): string | null { - if (type === "alert" || type === "workflow") return "/keep.png"; - return `/icons/${type - .replace("step-", "") - .replace("action-", "") - .replace("condition-", "")}-icon.png`; - } - - function CanDeleteStep(step: Step, parentSequence: Sequence): boolean { - return !step.properties["isLocked"]; - } - function IsStepDraggable(step: Step, parentSequence: Sequence): boolean { - return CanDeleteStep(step, parentSequence); - } - - function CanMoveStep( - sourceSequence: any, - step: any, - targetSequence: Sequence, - targetIndex: number - ): boolean { - return CanDeleteStep(step, sourceSequence); - } - - const validatorConfiguration: ValidatorConfiguration = { + const ValidatorConfigurationV2: { + step: (step: V2Step, + parent?: V2Step, + definition?: ReactFlowDefinition) => boolean; + root: (def: FlowDefinition) => boolean; + } = { step: (step, parent, definition) => - stepValidator(step, parent, definition, setStepValidationError), - root: (def) => globalValidator(def, setGlobalValidationError), - }; - - const stepsConfiguration: StepsConfiguration = { - iconUrlProvider: IconUrlProvider, - canDeleteStep: CanDeleteStep, - canMoveStep: CanMoveStep, - isDraggable: IsStepDraggable, - }; + stepValidatorV2(step, setStepValidationErrorV2, parent, definition), + root: (def) => globalValidatorV2(def, setGlobalValidationErrorV2), + } function closeGenerateModal() { setGenerateModalIsOpen(false); @@ -282,8 +263,30 @@ function Builder({ setRunningWorkflowExecution(null); }; + const getworkflowStatus = () => { + return stepValidationError || globalValidationError ? ( + + {stepValidationError || globalValidationError} + + ) : ( + + Alert can be generated successfully + + ); + }; + return ( - <> +
    {generateModalIsOpen || testRunModalOpen ? null : ( <> - {stepValidationError || globalValidationError ? ( - - {stepValidationError || globalValidationError} - - ) : ( - - Alert can be generated successfully - - )} - } - stepEditor={} - /> + {getworkflowStatus()} +
    + + { + setDefinition({ + value: { + sequence: def?.sequence || [], + properties + : def?. + properties + || {} + }, isValid: def?.isValid || false + }) + } + } + toolboxConfiguration={getToolboxConfiguration(providers)} + /> + +
    )} - +
    ); } diff --git a/keep-ui/app/workflows/builder/editors.tsx b/keep-ui/app/workflows/builder/editors.tsx index 945c4a30b..c7a44771f 100644 --- a/keep-ui/app/workflows/builder/editors.tsx +++ b/keep-ui/app/workflows/builder/editors.tsx @@ -7,13 +7,9 @@ import { Subtitle, Icon, Button, + Divider, } from "@tremor/react"; import { KeyIcon } from "@heroicons/react/20/solid"; -import { Properties } from "sequential-workflow-designer"; -import { - useStepEditor, - useGlobalEditor, -} from "sequential-workflow-designer-react"; import { Provider } from "app/providers/providers"; import { BackspaceIcon, @@ -22,13 +18,17 @@ import { FunnelIcon, HandRaisedIcon, } from "@heroicons/react/24/outline"; +import useStore, { V2Properties } from "./builder-store"; +import { useEffect, useState } from "react"; function EditorLayout({ children }: { children: React.ReactNode }) { return
    {children}
    ; } -export function GlobalEditor() { - const { properties, setProperty } = useGlobalEditor(); + +export function GlobalEditorV2({synced}: {synced: boolean}) { + const { v2Properties: properties, updateV2Properties: setProperty, selectedNode } = useStore(); + return ( Keep Workflow Editor @@ -37,21 +37,27 @@ export function GlobalEditor() { workflow YAML specifications. - Use the toolbox to add steps, conditions and actions to your workflow - and click the `Generate` button to compile the workflow / `Deploy` - button to deploy the workflow to Keep. + Use the edge add button or an empty step (a step with a +) to insert steps, conditions, and actions into your workflow. + Then, click the Generate button to compile the workflow or the Deploy button to deploy it to Keep. - {WorkflowEditor(properties, setProperty)} +
    {synced ? "Synced" : "Not Synced"}
    +
    ); } + interface keepEditorProps { - properties: Properties; - updateProperty: (key: string, value: any) => void; + properties: V2Properties; + updateProperty: ((key: string, value: any) => void); installedProviders?: Provider[] | null | undefined; providerType?: string; type?: string; + isV2?:boolean } function KeepStepEditor({ @@ -243,19 +249,22 @@ function KeepForeachEditor({ properties, updateProperty }: keepEditorProps) { ); } -function WorkflowEditor(properties: Properties, updateProperty: any) { - /** - * TODO: support generate, add more triggers and complex filters - * Need to think about UX for this - */ - const propertyKeys = Object.keys(properties).filter( - (k) => k !== "isLocked" && k !== "id" - ); +function WorkflowEditorV2({ + properties, + setProperties, + selectedNode +}: { + properties: V2Properties; + setProperties: (updatedProperties: V2Properties) => void; + selectedNode: string | null; +}) { + const isTrigger = ['interval', 'manual', 'alert'].includes(selectedNode || '') + const updateAlertFilter = (filter: string, value: string) => { - const currentFilters = properties.alert as {}; + const currentFilters = properties.alert || {}; const updatedFilters = { ...currentFilters, [filter]: value }; - updateProperty("alert", updatedFilters); + setProperties({ ...properties, alert: updatedFilters }); }; const addFilter = () => { @@ -265,77 +274,44 @@ function WorkflowEditor(properties: Properties, updateProperty: any) { } }; - const addTrigger = (trigger: "manual" | "interval" | "alert") => { - updateProperty( - trigger, - trigger === "alert" ? { source: "" } : trigger === "manual" ? "true" : "" - ); - }; const deleteFilter = (filter: string) => { - const currentFilters = properties.alert as any; + const currentFilters = { ...properties.alert }; delete currentFilters[filter]; - updateProperty("alert", currentFilters); + setProperties({ ...properties, alert: currentFilters }); }; + const propertyKeys = Object.keys(properties).filter( + (k) => k !== "isLocked" && k !== "id" + ); + let renderDivider = false; return ( <> Workflow Settings -
    - {Object.keys(properties).includes("manual") ? null : ( - - )} - {Object.keys(properties).includes("interval") ? null : ( - - )} - {Object.keys(properties).includes("alert") ? null : ( - - )} -
    {propertyKeys.map((key, index) => { + const isTrigger = ["manual", "alert", 'interval'].includes(key) ; + renderDivider = isTrigger && key === selectedNode ? !renderDivider : false; return ( -
    - {key} +
    + { renderDivider && } + {((key === selectedNode)||(!isTrigger)) && {key}} {key === "manual" ? ( -
    + selectedNode === 'manual' &&
    - updateProperty(key, e.target.checked ? "true" : "false") + setProperties({ + ...properties, + [key]: e.target.checked ? "true" : "false", + }) } + disabled={true} />
    ) : key === "alert" ? ( - <> -
    + selectedNode === 'alert' && <> +
    ); })} @@ -387,19 +372,56 @@ function WorkflowEditor(properties: Properties, updateProperty: any) { ); } -export default function StepEditor({ + + +export function StepEditorV2({ installedProviders, + setSynced }: { installedProviders?: Provider[] | undefined | null; + setSynced: (sync:boolean) => void; }) { - const { type, componentType, name, setName, properties, setProperty } = - useStepEditor(); + const [formData, setFormData] = useState<{ name?: string; properties?: V2Properties, type?:string }>({}); + const { + selectedNode, + updateSelectedNodeData, + setOpneGlobalEditor, + getNodeById + } = useStore(); + + useEffect(() => { + if (selectedNode) { + const { data } = getNodeById(selectedNode) || {}; + const { name, type, properties } = data || {}; + setFormData({ name, type , properties }); + } + }, [selectedNode, getNodeById]); + + if (!selectedNode) return null; + + const providerType = formData?.type?.split("-")[1]; + + const handleInputChange = (e: React.ChangeEvent) => { + setFormData({ ...formData, [e.target.name]: e.target.value }); + setSynced(false); + }; + + const handlePropertyChange = (key: string, value: any) => { + setFormData({ + ...formData, + properties: { ...formData.properties, [key]: value }, + }); + setSynced(false); + }; - function onNameChanged(e: any) { - setName(e.target.value); - } - const providerType = type.split("-")[1]; + const handleSubmit = () => { + // Finalize the changes before saving + updateSelectedNodeData('name', formData.name); + updateSelectedNodeData('properties', formData.properties); + }; + + const type = formData ? formData.type?.includes("step-") || formData.type?.includes("action-") : ""; return ( @@ -408,33 +430,40 @@ export default function StepEditor({ - {type.includes("step-") || type.includes("action-") ? ( + {type && formData.properties ? ( - ) : type === "condition-threshold" ? ( + ) : formData.type === "condition-threshold" ? ( - ) : type.includes("foreach") ? ( + ) : formData.type?.includes("foreach") ? ( - ) : type === "condition-assert" ? ( + ) : formData.type === "condition-assert" ? ( ) : null} + ); -} +} \ No newline at end of file diff --git a/keep-ui/app/workflows/builder/page.client.tsx b/keep-ui/app/workflows/builder/page.client.tsx index 3d25db74c..73f9223d2 100644 --- a/keep-ui/app/workflows/builder/page.client.tsx +++ b/keep-ui/app/workflows/builder/page.client.tsx @@ -69,7 +69,7 @@ export default function PageClient({ const incrementState = (s: number) => s + 1; return ( -
    +
    diff --git a/keep-ui/app/workflows/builder/page.css b/keep-ui/app/workflows/builder/page.css index 8d1c3cd58..e3e5803c9 100644 --- a/keep-ui/app/workflows/builder/page.css +++ b/keep-ui/app/workflows/builder/page.css @@ -24,4 +24,4 @@ position: relative; display: inline-block; border-bottom: 1px dotted black; /* If you want dots under the hoverable text */ -} +} \ No newline at end of file diff --git a/keep-ui/app/workflows/builder/types.tsx b/keep-ui/app/workflows/builder/types.tsx index 9f690da77..84a3928df 100644 --- a/keep-ui/app/workflows/builder/types.tsx +++ b/keep-ui/app/workflows/builder/types.tsx @@ -1,6 +1,3 @@ -import { Step } from "sequential-workflow-designer"; -export interface KeepStep extends Step {} - export interface LogEntry { timestamp: string; message: string; diff --git a/keep-ui/app/workflows/builder/utils.tsx b/keep-ui/app/workflows/builder/utils.tsx index 253ddbdde..d89ef574e 100644 --- a/keep-ui/app/workflows/builder/utils.tsx +++ b/keep-ui/app/workflows/builder/utils.tsx @@ -1,16 +1,10 @@ import { load, JSON_SCHEMA } from "js-yaml"; import { Provider } from "../../providers/providers"; -import { - BranchedStep, - Definition, - SequentialStep, - Step, - StepDefinition, - Uid, -} from "sequential-workflow-designer"; -import { KeepStep } from "./types"; import { Action, Alert } from "./alert"; import { stringify } from "yaml"; +import { V2Properties, V2Step, Definition } from "./builder-store"; +import { v4 as uuidv4 } from "uuid"; + export function getToolboxConfiguration(providers: Provider[]) { /** @@ -24,7 +18,7 @@ export function getToolboxConfiguration(providers: Provider[]) { stepParams: provider.query_params!, actionParams: provider.notify_params!, }, - }; + } as Partial<V2Step>; if (provider.can_query) steps.push({ ...step, @@ -39,10 +33,44 @@ export function getToolboxConfiguration(providers: Provider[]) { }); return [steps, actions]; }, - [[] as StepDefinition[], [] as StepDefinition[]] + [[] as Partial<V2Step>[], [] as Partial<V2Step>[]] ); return { groups: [ + { + name: "Triggers", + steps: [ + { + type: "manual", + componentType: "trigger", + name: "Manual", + id: 'manual', + properties: { + manual: "true", + }, + }, + { + type: "interval", + componentType: "trigger", + name: "Interval", + id: 'interval', + properties: { + interval: "" + }, + }, + { + type: "alert", + componentType: "trigger", + name: "Alert", + id: 'alert', + properties: { + alert: { + source: "", + } + }, + }, + ], + }, { name: "Steps", steps: steps, @@ -103,14 +131,14 @@ export function getActionOrStepObj( actionOrStep: any, type: "action" | "step", providers?: Provider[] -): KeepStep { +): V2Step { /** * Generate a step or action definition (both are kinda the same) */ const providerType = actionOrStep.provider?.type; const provider = providers?.find((p) => p.type === providerType); return { - id: Uid.next(), + id: actionOrStep?.id || uuidv4(), name: actionOrStep.name, componentType: "task", type: `${type}-${providerType}`, @@ -134,7 +162,7 @@ function generateForeach( sequence?: any ) { return { - id: Uid.next(), + id: actionOrStep?.id || uuidv4(), type: "foreach", componentType: "container", name: "Foreach", @@ -154,7 +182,7 @@ export function generateCondition( ): any { const stepOrAction = action.type === "step" ? "step" : "action"; const generatedCondition = { - id: Uid.next(), + id: condition.id || uuidv4(), name: condition.name, type: `condition-${condition.type}`, componentType: "switch", @@ -183,8 +211,8 @@ export function generateWorkflow( workflowId: string, name: string, description: string, - steps: Step[], - conditions: Step[], + steps: V2Step[], + conditions: V2Step[], triggers: { [key: string]: { [key: string]: string } } = {} ): Definition { /** @@ -217,9 +245,9 @@ export function parseWorkflow( const workflow = parsedWorkflowFile.alert ? parsedWorkflowFile.alert : parsedWorkflowFile.workflow; - const steps = [] as any; + const steps = [] as V2Step[]; const workflowSteps = - workflow.steps?.map((s: any) => { + workflow.steps?.map((s: V2Step) => { s.type = "step"; return s; }) || []; @@ -283,7 +311,11 @@ export function parseWorkflow( ); } -function getWithParams(s: Step): any { +function getWithParams(s: V2Step): any { + if (!s) { + return; + } + s.properties = (s.properties || {}) as V2Properties; const withParams = (s.properties.with as { [key: string]: string | number | boolean | object; @@ -294,14 +326,14 @@ function getWithParams(s: Step): any { const withParamValue = withParams[key] as string; const withParamJson = JSON.parse(withParamValue); withParams[key] = withParamJson; - } catch {} + } catch { } }); } return withParams; } function getActionsFromCondition( - condition: BranchedStep, + condition: V2Step, foreach?: string ): Action[] { const compiledCondition = { @@ -309,11 +341,12 @@ function getActionsFromCondition( type: condition.type.replace("condition-", ""), ...condition.properties, }; - const compiledActions = condition.branches.true.map((a) => { + const steps = condition?.branches?.true || [] as V2Step[]; + const compiledActions = steps.map((a: V2Step) => { const withParams = getWithParams(a); - const providerType = a.type.replace("action-", ""); + const providerType = a?.type?.replace("action-", ""); const providerName = - (a.properties.config as string)?.trim() || `default-${providerType}`; + (a?.properties?.config as string)?.trim() || `default-${providerType}`; const provider = { type: a.type.replace("action-", ""), config: `{{ providers.${providerName} }}`, @@ -393,7 +426,7 @@ export function buildAlert(definition: Definition): Alert { if: ifParam as string, }; } - else{ + else { return { name: s.name, provider: provider, @@ -404,15 +437,16 @@ export function buildAlert(definition: Definition): Alert { alert.sequence .filter((step) => step.type === "foreach") ?.forEach((forEach) => { - const forEachValue = forEach.properties.value as string; - const condition = (forEach as SequentialStep).sequence.find((c) => + const forEachValue = forEach?.properties?.value as string; + const condition = forEach?.sequence?.find((c) => c.type.startsWith("condition-") - ) as BranchedStep; + ) as V2Step; let foreachActions = [] as Action[]; if (condition) { foreachActions = getActionsFromCondition(condition, forEachValue); } else { - const stepOrAction = (forEach as SequentialStep).sequence[0]; + const forEachSequence = forEach?.sequence || [] as V2Step[]; + const stepOrAction = forEachSequence[0] || {} as V2Step[]; const withParams = getWithParams(stepOrAction); const providerType = stepOrAction.type .replace("action-", "") @@ -421,14 +455,14 @@ export function buildAlert(definition: Definition): Alert { const providerName = (stepOrAction.properties.config as string)?.trim() || `default-${providerType}`; - const provider: any = { + const provider = { type: stepOrAction.type.replace("action-", "").replace("step-", ""), config: `{{ providers.${providerName} }}`, with: withParams, }; foreachActions = [ { - name: stepOrAction.name, + name: stepOrAction.name || '', provider: provider, foreach: forEachValue, if: ifParam as string, @@ -442,7 +476,7 @@ export function buildAlert(definition: Definition): Alert { .filter((step) => step.type.startsWith("condition-")) ?.forEach((condition) => { const conditionActions = getActionsFromCondition( - condition as BranchedStep + condition as V2Step ); actions = [...actions, ...conditionActions]; }); @@ -470,7 +504,8 @@ export function buildAlert(definition: Definition): Alert { value: alert.properties.interval, }); } - const compiledAlert = { + + return { id: alertId, name: name, triggers: triggers, @@ -479,6 +514,16 @@ export function buildAlert(definition: Definition): Alert { services: services, steps: steps, actions: actions, - }; - return compiledAlert; + } as Alert; } + + +export function wrapDefinitionV2({ properties, sequence, isValid }: { properties: V2Properties, sequence: V2Step[], isValid?: boolean }) { + return { + value: { + sequence: sequence, + properties: properties + }, + isValid: !!isValid + } +} \ No newline at end of file diff --git a/keep-ui/app/workflows/preview/[workflowId]/page.tsx b/keep-ui/app/workflows/preview/[workflowId]/page.tsx index 58826f465..fb6461939 100644 --- a/keep-ui/app/workflows/preview/[workflowId]/page.tsx +++ b/keep-ui/app/workflows/preview/[workflowId]/page.tsx @@ -29,7 +29,7 @@ export default function PageWithId({ {workflowPreviewData && workflowPreviewData.name === key && ( <PageClient workflow={workflowPreviewData.workflow_raw || ""} - workflowId={key || ""} + isPreview={true} /> )} {workflowPreviewData && workflowPreviewData.name !== key && ( diff --git a/keep-ui/package-lock.json b/keep-ui/package-lock.json index a94a256dd..876bb0524 100644 --- a/keep-ui/package-lock.json +++ b/keep-ui/package-lock.json @@ -29,7 +29,7 @@ "@tanstack/react-table": "^8.11.0", "@tremor/react": "^3.15.1", "@types/react-select": "^5.0.1", - "@xyflow/react": "^12.0.2", + "@xyflow/react": "^12.0.3", "add": "^2.0.6", "ajv": "^6.12.6", "ansi-regex": "^5.0.1", @@ -106,6 +106,7 @@ "dlv": "^1.1.3", "doctrine": "^3.0.0", "dom-helpers": "^5.2.1", + "elkjs": "^0.9.3", "enhanced-resolve": "^5.14.0", "error-ex": "^1.3.2", "es-abstract": "^1.21.2", @@ -323,8 +324,6 @@ "sass": "^1.63.6", "scheduler": "^0.23.0", "semver": "^7.5.2", - "sequential-workflow-designer": "^0.13.8", - "sequential-workflow-designer-react": "^0.13.8", "server-only": "^0.0.1", "sharp": "^0.32.6", "shebang-command": "^2.0.0", @@ -380,6 +379,7 @@ "@types/js-yaml": "^4.0.5", "@types/json-logic-js": "^2.0.7", "@types/json-query": "^2.2.6", + "@types/lodash.debounce": "^4.0.9", "@types/node": "20.2.1", "@types/react-datepicker": "^6.0.2", "@types/react-grid-layout": "^1.3.5", @@ -4066,6 +4066,21 @@ "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==" }, + "node_modules/@types/lodash": { + "version": "4.17.7", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.7.tgz", + "integrity": "sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==", + "dev": true + }, + "node_modules/@types/lodash.debounce": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/lodash.debounce/-/lodash.debounce-4.0.9.tgz", + "integrity": "sha512-Ma5JcgTREwpLRwMM+XwBR7DaWe96nC38uCBDFKZWbNKD+osjVzdpnUSwBcqCptrp16sSOLBAUb50Car5I0TCsQ==", + "dev": true, + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/mdast": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.3.tgz", @@ -6487,6 +6502,11 @@ "strongly-connected-components": "^1.0.1" } }, + "node_modules/elkjs": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.9.3.tgz", + "integrity": "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==" + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -12945,32 +12965,6 @@ "node": ">=10" } }, - "node_modules/sequential-workflow-designer": { - "version": "0.13.8", - "resolved": "https://registry.npmjs.org/sequential-workflow-designer/-/sequential-workflow-designer-0.13.8.tgz", - "integrity": "sha512-w+n+Yz6wWF2Hzm7pXoFo3khsGPZRN2tB1BUBJy3nKBE+/4sI0/hIAC/wNgkbCNysM0PD4mO9ZP8GVQEib7rzcg==", - "dependencies": { - "sequential-workflow-model": "^0.1.4" - }, - "peerDependencies": { - "sequential-workflow-model": "^0.1.4" - } - }, - "node_modules/sequential-workflow-designer-react": { - "version": "0.13.8", - "resolved": "https://registry.npmjs.org/sequential-workflow-designer-react/-/sequential-workflow-designer-react-0.13.8.tgz", - "integrity": "sha512-FQiVAHeIHU9Rmx0aqBemRgtWG0QZ7GBZrCoSNvRV9twv+NFueslELGQwMcdy0p04GWi7WX+RmGzb1b9+YYrgPg==", - "peerDependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0", - "sequential-workflow-designer": "^0.13.8" - } - }, - "node_modules/sequential-workflow-model": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/sequential-workflow-model/-/sequential-workflow-model-0.1.4.tgz", - "integrity": "sha512-z44I8CQqP51sO7gHqH0/7GjmBOt2yczmmEGdRdJaczogDtZ1E6kucLkcvA4LugXFFuxTilYldY7NKFFTK+XWDA==" - }, "node_modules/server-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", diff --git a/keep-ui/package.json b/keep-ui/package.json index 2b3c7f3c8..770f88fb4 100644 --- a/keep-ui/package.json +++ b/keep-ui/package.json @@ -30,7 +30,7 @@ "@tanstack/react-table": "^8.11.0", "@tremor/react": "^3.15.1", "@types/react-select": "^5.0.1", - "@xyflow/react": "^12.0.2", + "@xyflow/react": "^12.0.3", "add": "^2.0.6", "ajv": "^6.12.6", "ansi-regex": "^5.0.1", @@ -107,6 +107,7 @@ "dlv": "^1.1.3", "doctrine": "^3.0.0", "dom-helpers": "^5.2.1", + "elkjs": "^0.9.3", "enhanced-resolve": "^5.14.0", "error-ex": "^1.3.2", "es-abstract": "^1.21.2", @@ -324,8 +325,6 @@ "sass": "^1.63.6", "scheduler": "^0.23.0", "semver": "^7.5.2", - "sequential-workflow-designer": "^0.13.8", - "sequential-workflow-designer-react": "^0.13.8", "server-only": "^0.0.1", "sharp": "^0.32.6", "shebang-command": "^2.0.0", @@ -381,6 +380,7 @@ "@types/js-yaml": "^4.0.5", "@types/json-logic-js": "^2.0.7", "@types/json-query": "^2.2.6", + "@types/lodash.debounce": "^4.0.9", "@types/node": "20.2.1", "@types/react-datepicker": "^6.0.2", "@types/react-grid-layout": "^1.3.5", diff --git a/keep-ui/utils/helpers.ts b/keep-ui/utils/helpers.ts index 969d19e95..11bf31af5 100644 --- a/keep-ui/utils/helpers.ts +++ b/keep-ui/utils/helpers.ts @@ -57,7 +57,6 @@ export async function installWebhook(provider: Provider, accessToken: string) { success: `${provider.type} webhook installed 👌`, error: { render({ data }) { - console.log(data); // When the promise reject, data will contains the error return `Webhook installation failed 😢 Error: ${ (data as any).detail diff --git a/keep-ui/utils/hooks/useWorkflowInitialization.ts b/keep-ui/utils/hooks/useWorkflowInitialization.ts new file mode 100644 index 000000000..828376da9 --- /dev/null +++ b/keep-ui/utils/hooks/useWorkflowInitialization.ts @@ -0,0 +1,232 @@ +import { + useEffect, + useState, + useCallback, +} from "react"; +import { Edge, useReactFlow } from "@xyflow/react"; +import useStore, { Definition, ReactFlowDefinition, V2Step } from "../../app/workflows/builder/builder-store"; +import { FlowNode } from "../../app/workflows/builder/builder-store"; +import { Provider } from "app/providers/providers"; +import ELK from 'elkjs/lib/elk.bundled.js'; +import { processWorkflowV2, getTriggerStep } from "utils/reactFlow"; + +const layoutOptions = { + "elk.nodeLabels.placement": "INSIDE V_CENTER H_BOTTOM", + "elk.algorithm": "layered", + "elk.direction": "BOTTOM", + "org.eclipse.elk.layered.layering.strategy": "INTERACTIVE", + "elk.edgeRouting": "ORTHOGONAL", + "elk.layered.unnecessaryBendpoints": false, + "elk.layered.spacing.edgeNodeBetweenLayers": "70", + "org.eclipse.elk.layered.nodePlacement.bk.fixedAlignment": "BALANCED", + "org.eclipse.elk.layered.cycleBreaking.strategy": "DEPTH_FIRST", + "elk.insideSelfLoops.activate": true, + "separateConnectedComponents": "false", + "spacing.componentComponent": "80", + "spacing": "80", + "elk.spacing.nodeNodeBetweenLayers": "80", + "elk.spacing.nodeNode": "120", + "elk.layered.spacing.nodeNodeBetweenLayers": "80", + "portConstraints": "FIXED_ORDER", + "nodeSize.constraints": "[MINIMUM_SIZE]", + "elk.alignment": "CENTER", + "elk.spacing.edgeNodeBetweenLayers": "70.0", + "org.eclipse.elk.layoutAncestors": "true", + "elk.layered.nodePlacement.strategy": "BRANDES_KOEPF", + "elk.layered.nodePlacement.outerSpacing": "30", + "elk.layered.nodePlacement.outerPadding": "30", + "elk.layered.edgeRouting.orthogonal": true, + + // Avoid bending towards nodes + "elk.layered.allowEdgeLabelOverlap": false, + "elk.layered.edgeRouting.avoidNodes": true, + "elk.layered.edgeRouting.avoidEdges": true, + "elk.layered.nodePlacement.nodeNodeOverlapAllowed": false, + "elk.layered.consistentLevelSpacing": true +} + + +const getLayoutedElements = (nodes: FlowNode[], edges: Edge[], options = {}) => { + // @ts-ignore + const isHorizontal = options?.['elk.direction'] === 'RIGHT'; + const elk = new ELK(); + + const graph = { + id: 'root', + layoutOptions: options, + children: nodes.map((node) => { + const type = node?.data?.type + ?.replace("step-", "") + ?.replace("action-", "") + ?.replace("condition-", "") + ?.replace("__end", ""); + return ({ + ...node, + // Adjust the target and source handle positions based on the layout + // direction. + targetPosition: isHorizontal ? 'left' : 'top', + sourcePosition: isHorizontal ? 'right' : 'bottom', + + // Hardcode a width and height for elk to use when layouting. + width: ['start', 'end'].includes(type) ? 80 : 280, + height: 80, + }) + }), + edges: edges, + }; + + return elk + // @ts-ignore + .layout(graph) + .then((layoutedGraph) => ({ + nodes: layoutedGraph?.children?.map((node) => ({ + ...node, + // React Flow expects a position property on the node instead of `x` + // and `y` fields. + position: { x: node.x, y: node.y }, + })), + + edges: layoutedGraph.edges, + })) + .catch(console.error); +}; + +const useWorkflowInitialization = ( + definition: ReactFlowDefinition, + toolboxConfiguration: Record<string, any> +) => { + const { + nodes, + edges, + setNodes, + setEdges, + onNodesChange, + onEdgesChange, + onConnect, + onDragOver, + onDrop, + setV2Properties, + openGlobalEditor, + selectedNode, + setToolBoxConfig, + isLayouted, + setIsLayouted, + setChanges, + setSelectedNode, + setFirstInitilisationDone + } = useStore(); + + const [isLoading, setIsLoading] = useState(true); + const { screenToFlowPosition } = useReactFlow(); + const [finalNodes, setFinalNodes] = useState<FlowNode[]>([]); + const [finalEdges, setFinalEdges] = useState<Edge[]>([]); + + const handleDrop = useCallback( + (event: React.DragEvent<HTMLDivElement>) => { + onDrop(event, screenToFlowPosition); + }, + [screenToFlowPosition] + ); + + const onLayout = useCallback( + ({ direction, useInitialNodes = false, initialNodes, initialEdges }: { + direction: string; + useInitialNodes?: boolean; + initialNodes?: FlowNode[], + initialEdges?: Edge[] + }) => { + const opts = { ...layoutOptions, 'elk.direction': direction }; + const ns = useInitialNodes ? initialNodes : nodes; + const es = useInitialNodes ? initialEdges : edges; + + // @ts-ignore + getLayoutedElements(ns, es, opts).then( + // @ts-ignore + ({ nodes: layoutedNodes, edges: layoutedEdges }) => { + layoutedEdges = layoutedEdges.map((edge: Edge) => { + return { + ...edge, + animated: !!edge?.target?.includes('empty'), + data: { ...edge.data, isLayouted: true } + }; + }) + layoutedNodes.forEach((node: FlowNode) => { + node.data = { ...node.data, isLayouted: true } + }) + setNodes(layoutedNodes); + setEdges(layoutedEdges); + setIsLayouted(true); + setFinalEdges(layoutedEdges); + setFinalNodes(layoutedNodes); + + }, + ); + }, + [nodes, edges], + ); + + useEffect(() => { + if (!isLayouted && nodes.length > 0) { + onLayout({ direction: 'DOWN' }) + } + }, [nodes, edges]) + + useEffect(() => { + const initializeWorkflow = async () => { + setIsLoading(true); + let parsedWorkflow = definition?.value; + setV2Properties({ ...(parsedWorkflow?.properties ?? {}), name: parsedWorkflow?.properties?.name ?? parsedWorkflow?.properties?.id }); + + const sequences = [ + { + id: "start", + type: "start", + componentType: "start", + properties: {}, + isLayouted: false, + name: "start" + } as V2Step, + ...(getTriggerStep(parsedWorkflow?.properties)), + ...(parsedWorkflow?.sequence || []), + { + id: "end", + type: "end", + componentType: "end", + properties: {}, + isLayouted: false, + name: "end" + } as V2Step, + ]; + const intialPositon = { x: 0, y: 50 }; + let { nodes, edges } = processWorkflowV2(sequences, intialPositon, true); + setSelectedNode(null); + setFirstInitilisationDone(false) + setChanges(0); + setIsLayouted(false); + setNodes(nodes); + setEdges(edges); + setToolBoxConfig(toolboxConfiguration); + setIsLoading(false); + }; + initializeWorkflow(); + }, []); + + + return { + nodes: finalNodes, + edges: finalEdges, + isLoading, + onNodesChange: onNodesChange, + onEdgesChange: onEdgesChange, + onConnect: onConnect, + onDragOver: onDragOver, + onDrop: handleDrop, + openGlobalEditor, + selectedNode, + setNodes, + toolboxConfiguration, + isLayouted, + }; +}; + +export default useWorkflowInitialization; \ No newline at end of file diff --git a/keep-ui/utils/reactFlow.ts b/keep-ui/utils/reactFlow.ts new file mode 100644 index 000000000..4c4d7aa3b --- /dev/null +++ b/keep-ui/utils/reactFlow.ts @@ -0,0 +1,503 @@ +import { FlowNode, NodeData, V2Properties, V2Step } from "app/workflows/builder/builder-store"; +import { Edge } from "@xyflow/react"; + + +function getKeyBasedSquence(step: V2Step, id: string, type: string) { + return `${step.type}__${id}__empty_${type}`; +} + + +export function reConstructWorklowToDefinition({ + nodes, + edges, + properties = {} +}: { + nodes: FlowNode[], + edges: Edge[], + properties: Record<string, any> +}) { + + let originalNodes = nodes.slice(1, nodes.length - 1); + originalNodes = originalNodes.filter((node) => !node.data.componentType.includes('trigger')); + function processForeach(startIdx: number, endIdx: number, foreachNode: FlowNode['data'], nodeId: string) { + foreachNode.sequence = []; + + const tempSequence = []; + const foreachEmptyId = `${foreachNode.type}__${nodeId}__empty_foreach`; + + for (let i = startIdx; i < endIdx; i++) { + const currentNode = originalNodes[i]; + const { isLayouted, ...nodeData } = currentNode?.data; + const nodeType = nodeData?.type; + if (currentNode.id === foreachEmptyId) { + foreachNode.sequence = tempSequence; + return i + 1; + } + + if (["condition-threshold", "condition-assert"].includes(nodeType)) { + tempSequence.push(nodeData); + i = processCondition(i + 1, endIdx, nodeData, currentNode.id); + continue; + } + + if (nodeType === "foreach") { + tempSequence.push(nodeData); + i = processForeach(i + 1, endIdx, nodeData, currentNode.id); + continue; + } + + tempSequence.push(nodeData); + } + return endIdx; + } + + function processCondition(startIdx: number, endIdx: number, conditionNode: FlowNode['data'], nodeId: string) { + conditionNode.branches = { + true: [], + false: [], + }; + + const trueBranchEmptyId = `${conditionNode?.type}__${nodeId}__empty_true`; + const falseBranchEmptyId = `${conditionNode?.type}__${nodeId}__empty_false`; + let trueCaseAdded = false; + let falseCaseAdded = false; + let tempSequence = []; + let i = startIdx; + for (; i < endIdx; i++) { + const currentNode = originalNodes[i]; + const { isLayouted, ...nodeData } = currentNode?.data; + const nodeType = nodeData?.type; + if (trueCaseAdded && falseCaseAdded) { + return i; + } + if (currentNode.id === trueBranchEmptyId) { + conditionNode.branches.true = tempSequence; + trueCaseAdded = true; + tempSequence = []; + continue; + } + + if (currentNode.id === falseBranchEmptyId) { + conditionNode.branches.false = tempSequence; + falseCaseAdded = true; + tempSequence = []; + continue; + } + + if (["condition-threshold", "condition-assert"].includes(nodeType)) { + tempSequence.push(nodeData); + i = processCondition(i + 1, endIdx, nodeData, currentNode.id); + continue; + } + + if (nodeType === "foreach") { + tempSequence.push(nodeData); + i = processForeach(i + 1, endIdx, nodeData, currentNode.id); + continue; + } + tempSequence.push(nodeData); + } + return endIdx; + } + + function buildWorkflowDefinition(startIdx: number, endIdx: number) { + const workflowSequence = []; + for (let i = startIdx; i < endIdx; i++) { + const currentNode = originalNodes[i]; + const { isLayouted, ...nodeData } = currentNode?.data; + const nodeType = nodeData?.type; + if (["condition-threshold", "condition-assert"].includes(nodeType)) { + workflowSequence.push(nodeData); + i = processCondition(i + 1, endIdx, nodeData, currentNode.id); + continue; + } + if (nodeType === "foreach") { + workflowSequence.push(nodeData); + i = processForeach(i + 1, endIdx, nodeData, currentNode.id); + continue; + } + workflowSequence.push(nodeData); + } + return workflowSequence; + } + + const triggerNodes = nodes.reduce((obj, node) => { + if (['interval', 'alert', 'manual'].includes(node.id)) { + obj[node.id] = true; + } + return obj; + }, {} as Record<string, boolean>); + return { + sequence: buildWorkflowDefinition(0, originalNodes.length) as V2Step[], + properties: properties as V2Properties + } + +} + +export function createSwitchNodeV2( + step: V2Step, + nodeId: string, + position: FlowNode['position'], + nextNodeId?: string | null, + prevNodeId?: string | null, + isNested?: boolean, +): FlowNode[] { + const customIdentifier = `${step.type}__end__${nodeId}`; + const stepType = step?.type?.replace("step-", "")?.replace("condition-", "")?.replace("__end", "")?.replace("action-", ""); + const { name, type, componentType, properties } = step; + return [ + { + id: nodeId, + type: "custom", + position: { x: 0, y: 0 }, + data: { + label: name, + type, + componentType, + id: nodeId, + properties, + name: name + } as V2Step, + isDraggable: false, + prevNodeId, + nextNodeId: customIdentifier, + dragHandle: ".custom-drag-handle", + isNested: !!isNested + }, + { + id: customIdentifier, + type: "custom", + position: { x: 0, y: 0 }, + data: { + label: `${stepType} End`, + id: customIdentifier, + type: `${step.type}__end`, + name: `${stepType} End`, + componentType: `${step.type}__end`, + properties: {} + } as V2Step, + isDraggable: false, + prevNodeId: nodeId, + nextNodeId: nextNodeId, + dragHandle: ".custom-drag-handle", + isNested: !!isNested + }, + ]; +}; + + + +export function handleSwitchNode(step: V2Step, position: FlowNode['position'], nextNodeId: string, prevNodeId: string, nodeId: string, isNested: boolean) { + let trueBranches = step?.branches?.true || [] as FlowNode[]; + let falseBranches = step?.branches?.false || [] as Edge[]; + + + function _getEmptyNode(type: string) { + const key = `empty_${type}` + return { + id: `${step.type}__${nodeId}__${key}`, + type: key, + componentType: key, + name: "empty", + properties: {}, + isNested: true, + } as V2Step + } + + let [switchStartNode, switchEndNode] = createSwitchNodeV2(step, nodeId, position, nextNodeId, prevNodeId, isNested); + trueBranches = [ + { ...switchStartNode.data, type: 'temp_node', componentType: "temp_node" }, + ...trueBranches, + _getEmptyNode("true"), + { ...switchEndNode.data, type: 'temp_node', componentType: "temp_node" } + ] as V2Step[]; + falseBranches = [ + { ...switchStartNode.data, type: 'temp_node', componentType: "temp_node" }, + ...falseBranches, + _getEmptyNode("false"), + { ...switchEndNode.data, type: 'temp_node', componentType: "temp_node" } + ] as V2Step[] + + let truePostion = { x: position.x - 200, y: position.y - 100 }; + let falsePostion = { x: position.x + 200, y: position.y - 100 }; + + let { nodes: trueBranchNodes, edges: trueSubflowEdges } = + processWorkflowV2(trueBranches, truePostion, false, true) || {}; + let { nodes: falseSubflowNodes, edges: falseSubflowEdges } = + processWorkflowV2(falseBranches, falsePostion, false, true) || {}; + + function _adjustEdgeConnectionsAndLabelsForSwitch(type: string) { + if (!type) { + return; + } + const subflowEdges = type === 'True' ? trueSubflowEdges : falseSubflowEdges; + const subflowNodes = type === 'True' ? trueBranchNodes : falseSubflowNodes; + const [firstEdge] = subflowEdges; + firstEdge.label = type?.toString(); + firstEdge.id = `e${switchStartNode.prevNodeId}-${firstEdge.target || switchEndNode.id + }`; + firstEdge.source = switchStartNode.id || ""; + firstEdge.target = firstEdge.target || switchEndNode.id; + subflowEdges.pop(); + } + _adjustEdgeConnectionsAndLabelsForSwitch('True'); + _adjustEdgeConnectionsAndLabelsForSwitch('False'); + return { + nodes: [ + switchStartNode, + ...falseSubflowNodes, + ...trueBranchNodes, + switchEndNode, + ], edges: [ + ...falseSubflowEdges, + ...trueSubflowEdges, + //handling the switch end edge + ...createCustomEdgeMeta(switchEndNode.id, nextNodeId) + ] + }; + +} + +export const createDefaultNodeV2 = ( + step: V2Step | NodeData, + nodeId: string, + position?: FlowNode['position'], + nextNodeId?: string | null, + prevNodeId?: string | null, + isNested?: boolean, +): FlowNode => +({ + id: nodeId, + type: "custom", + dragHandle: ".custom-drag-handle", + position: { x: 0, y: 0 }, + data: { + label: step.name, + ...step, + }, + isDraggable: false, + nextNodeId, + prevNodeId, + isNested: !!isNested +} as FlowNode); + +const getRandomColor = () => { + const letters = '0123456789ABCDEF'; + let color = '#'; + for (let i = 0; i < 6; i++) { + color += letters[Math.floor(Math.random() * 16)]; + } + return color; +}; + +export function createCustomEdgeMeta(source: string | string[], target: string | string[], label?: string, color?: string, type?: string) { + + const finalSource = (Array.isArray(source) ? source : [source || ""]) as string[]; + const finalTarget = (Array.isArray(target) ? target : [target || ""]) as string[]; + + const edges = [] as Edge[]; + finalSource?.forEach((source) => { + finalTarget?.forEach((target) => { + edges.push({ + id: `e${source}-${target}`, + source: source ?? "", + target: target ?? "", + type: type || "custom-edge", + label, + style: { stroke: color || getRandomColor() } + } as Edge) + }) + }) + return edges; +} +export function handleDefaultNode(step: V2Step, position: FlowNode['position'], nextNodeId: string, prevNodeId: string, nodeId: string, isNested: boolean) { + const nodes = []; + let edges = [] as Edge[]; + const newNode = createDefaultNodeV2( + step, + nodeId, + position, + nextNodeId, + prevNodeId, + isNested + ); + if (step.type !== 'temp_node') { + nodes.push(newNode); + } + // Handle edge for default nodes + if (newNode.id !== "end" && !step.edgeNotNeeded) { + edges = [...edges, ...createCustomEdgeMeta(newNode.id, step.edgeTarget || nextNodeId, step.edgeLabel, step.edgeColor)]; + } + return { nodes, edges }; +} + +export function getForEachNode(step: V2Step, position: FlowNode['position'], nodeId: string, prevNodeId: string, nextNodeId: string, isNested: boolean) { + const { sequence, ...rest } = step; + const customIdentifier = `${step.type}__end__${nodeId}`; + + return [ + { + id: nodeId, + data: { ...rest, id: nodeId } as V2Step, + type: "custom", + position: { x: 0, y: 0 }, + isDraggable: false, + dragHandle: ".custom-drag-handle", + prevNodeId: prevNodeId, + nextNodeId: nextNodeId, + isNested: !!isNested, + }, + { + id: customIdentifier, + data: { ...rest, id: customIdentifier, label: "foreach end", type: `${step.type}__end`, name: 'Foreach End' } as V2Step, + type: "custom", + position: { x: 0, y: 0 }, + isDraggable: false, + dragHandle: ".custom-drag-handle", + prevNodeId: prevNodeId, + nextNodeId: nextNodeId, + isNested: !!isNested + }, + ] as FlowNode[]; +} + + +export function handleForeachNode(step: V2Step, position: FlowNode['position'], nextNodeId: string, prevNodeId: string, nodeId: string, isNested: boolean) { + + const [forEachStartNode, forEachEndNode] = getForEachNode(step, position, nodeId, prevNodeId, nextNodeId, isNested); + + function _getEmptyNode(type: string) { + const key = `empty_${type}` + return { + id: `${step.type}__${nodeId}__${key}`, + type: key, + componentType: key, + name: "empty", + properties: {}, + isNested: true, + } as V2Step + } + const sequences = [ + { id: prevNodeId, type: "temp_node", componentType: "temp_node", name: "temp_node", properties: {}, edgeNotNeeded: true }, + { id: forEachStartNode.id, type: "temp_node", componentType: "temp_node", name: "temp_node", properties: {} }, + ...(step?.sequence || []), + _getEmptyNode("foreach"), + { id: forEachEndNode.id, type: "temp_node", componentType: "temp_node", name: "temp_node", properties: {} }, + { id: nextNodeId, type: "temp_node", componentType: "temp_node", name: "temp_node", properties: {}, edgeNotNeeded: true }, + ] as V2Step[]; + const { nodes, edges } = processWorkflowV2(sequences, position, false, true); + return { nodes: [forEachStartNode, ...nodes, forEachEndNode], edges: edges }; +} + + +export const processStepV2 = ( + step: V2Step, + position: FlowNode['position'], + nextNodeId: string, + prevNodeId: string, + isNested: boolean +) => { + const nodeId = step.id; + let newNodes: FlowNode[] = []; + let newEdges: Edge[] = []; + switch (true) { + case step?.componentType === "switch": + { + const { nodes, edges } = handleSwitchNode(step, position, nextNodeId, prevNodeId, nodeId, isNested); + newEdges = [...newEdges, ...edges]; + newNodes = [...newNodes, ...nodes]; + break; + } + case step?.componentType === "container" && step?.type === "foreach": + { + const { nodes, edges } = handleForeachNode(step, position, nextNodeId, prevNodeId, nodeId, isNested); + newEdges = [...newEdges, ...edges]; + newNodes = [...newNodes, ...nodes]; + break; + } + default: + { + const { nodes, edges } = handleDefaultNode(step, position, nextNodeId, prevNodeId, nodeId, isNested); + newEdges = [...newEdges, ...edges]; + newNodes = [...newNodes, ...nodes]; + break; + } + } + + return { nodes: newNodes, edges: newEdges }; +}; + +export const processWorkflowV2 = (sequence: V2Step[], position: FlowNode['position'], isFirstRender = false, isNested = false) => { + let newNodes: FlowNode[] = []; + let newEdges: Edge[] = []; + + sequence?.forEach((step: any, index: number) => { + const prevNodeId = sequence?.[index - 1]?.id || ""; + const nextNodeId = sequence?.[index + 1]?.id || ""; + position.y += 150; + const { nodes, edges } = processStepV2( + step, + position, + nextNodeId, + prevNodeId, + isNested, + ); + newNodes = [...newNodes, ...nodes]; + newEdges = [...newEdges, ...edges]; + }); + + if (isFirstRender) { + newNodes = newNodes.map((node) => ({ ...node, isLayouted: false })); + newEdges = newEdges.map((edge) => ({ ...edge, isLayouted: false })); + } + return { nodes: newNodes, edges: newEdges }; +}; + + +export function getTriggerStep(properties: V2Properties) { + const _steps = [] as V2Step[]; + function _triggerSteps() { + + if (!properties) { + return _steps; + } + + Object.keys(properties).forEach((key) => { + if (['interval', 'manual', 'alert'].includes(key) && properties[key]) { + _steps.push({ + id: key, + type: key, + componentType: "trigger", + properties: properties[key], + name: key, + edgeTarget: 'trigger_end', + } as V2Step); + } + }) + return _steps; + } + + const steps = _triggerSteps(); + let triggerStartTargets: string | string[] = steps.map((step) => step.id); + triggerStartTargets = (triggerStartTargets.length ? triggerStartTargets : ""); + return [ + { + id: 'trigger_start', + name: 'Trigger Start', + type: 'trigger', + componentType: 'trigger', + edgeTarget: triggerStartTargets, + cantDelete: true, + }, + ...steps, + { + id: 'trigger_end', + name: 'Workflow start', + type: '', + componentType: 'trigger', + cantDelete: true, + } + + ] as V2Step[]; + +}