diff --git a/eslint.config.mjs b/eslint.config.mjs index c31cbd28ed..2a83b68ccf 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -116,7 +116,7 @@ export default [ { callees: ["cn"], cssFiles: ["src/assets/index.css", "src/assets/loader.css"], - whitelist: ["(.*)current"], + whitelist: ["(.*)current", "nodrag", "nopan", "react-flow__edge-interaction"], }, ], "@typescript-eslint/no-unused-expressions": "off", diff --git a/package-lock.json b/package-lock.json index 6906b5ef8d..dce54d2d71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "@types/lodash": "^4.17.13", "@types/pako": "^2.0.3", "@uiw/react-json-view": "^2.0.0-alpha.30", + "@xyflow/react": "^12.10.0", "axios": "^1.9.0", "clsx": "^2.1.1", "dayjs": "^1.11.13", @@ -7603,6 +7604,66 @@ "node": ">=18.0.0" } }, + "node_modules/@xyflow/react": { + "version": "12.10.0", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.0.tgz", + "integrity": "sha512-eOtz3whDMWrB4KWVatIBrKuxECHqip6PfA8fTpaS2RUGVpiEAe+nqDKsLqkViVWxDGreq0lWX71Xth/SPAzXiw==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.74", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/react/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.74", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.74.tgz", + "integrity": "sha512-7v7B/PkiVrkdZzSbL+inGAo6tkR/WQHHG0/jhSvLQToCsfa8YubOGmBYd1s08tpKpihdHDZFwzQZeR69QSBb4Q==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/@yr/monotone-cubic-spline": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz", @@ -8799,6 +8860,12 @@ "node": ">=8" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/clean-regexp": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/clean-regexp/-/clean-regexp-1.0.0.tgz", diff --git a/package.json b/package.json index 4195231e8b..9abe7ebd42 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "@types/lodash": "^4.17.13", "@types/pako": "^2.0.3", "@uiw/react-json-view": "^2.0.0-alpha.30", + "@xyflow/react": "^12.10.0", "axios": "^1.9.0", "clsx": "^2.1.1", "dayjs": "^1.11.13", diff --git a/src/components/molecules/modal.tsx b/src/components/molecules/modal.tsx index 6e9e45bd93..7cb9f609ed 100644 --- a/src/components/molecules/modal.tsx +++ b/src/components/molecules/modal.tsx @@ -35,6 +35,7 @@ export const Modal = ({ forceOpen, onCloseCallbackOverride, clickOverlayToClose, + variant, }: ModalProps) => { const { isOpen, onClose } = useModalStore((state) => { const onClose = state.closeModal; @@ -47,9 +48,23 @@ export const Modal = ({ const modalRef = useRef(null); const wrapperClassName = cn("fixed left-0 top-0 z-modal flex size-full items-center justify-center", wrapperClass); - const modalClasses = cn("w-500 rounded-2xl border border-gray-950 bg-white p-3.5 text-gray-1250", className); + const modalClasses = cn( + "w-500 rounded-2xl border p-3.5", + { + "border-gray-950 bg-white text-gray-1250": variant !== "dark", + "border-gray-700 bg-gray-950 text-white": variant === "dark", + }, + className + ); const bgClass = cn("absolute left-0 top-0 z-modal-overlay size-full bg-black/70"); - const closeButtonClasseName = cn("group ml-auto h-default-icon w-default-icon bg-gray-250 p-0", closeButtonClass); + const closeButtonClasseName = cn( + "group ml-auto h-default-icon w-default-icon p-0", + { + "bg-gray-250": variant !== "dark", + "bg-gray-800": variant === "dark", + }, + closeButtonClass + ); useEffect(() => { if (isOpen && modalRef.current) { const buttons = modalRef.current.querySelectorAll("button"); @@ -124,7 +139,12 @@ export const Modal = ({ > {hideCloseButton ? null : ( onClose(name)}> - + )} diff --git a/src/components/organisms/configuration/configrationDrawer.tsx b/src/components/organisms/configuration/configrationDrawer.tsx index 0c06740e20..8d5233b77b 100644 --- a/src/components/organisms/configuration/configrationDrawer.tsx +++ b/src/components/organisms/configuration/configrationDrawer.tsx @@ -1,6 +1,7 @@ -import React, { useCallback, useEffect, useMemo } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; -import { useLocation, useParams } from "react-router-dom"; +import { LuLayoutDashboard } from "react-icons/lu"; +import { Link, useLocation, useParams } from "react-router-dom"; import { defaultProjectSettingsWidth } from "@src/constants"; import { EventListenerName } from "@src/enums"; @@ -14,6 +15,113 @@ import { ResizeButton } from "@components/atoms"; import { Drawer } from "@components/molecules"; import { ConfigurationBySubPath } from "@components/organisms/configuration/configurationBySubPath"; +const VisualModeButton = ({ projectId }: { projectId: string }) => { + const [isHovered, setIsHovered] = useState(false); + + return ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + to={`/projects/${projectId}/canvas`} + > + + + + + + + + + + + + + + + + +
+ + + Visual Mode + +
+ + + + ); +}; + export const ProjectConfigurationDrawer = () => { const { projectId } = useParams(); const location = useLocation(); @@ -80,6 +188,7 @@ export const ProjectConfigurationDrawer = () => { width={drawerWidth} wrapperClassName="p-0 relative absolute rounded-r-2xl" > + {!location.pathname.includes("/canvas") && projectId ? : null} = { + draft: { stroke: "#6b7280", bg: "bg-gray-700", border: "border-gray-500", text: "text-gray-400" }, + configured: { stroke: "#22c55e", bg: "bg-gray-900", border: "border-green-500", text: "text-green-400" }, + active: { stroke: "#3b82f6", bg: "bg-blue-900", border: "border-blue-500", text: "text-blue-400" }, + error: { stroke: "#ef4444", bg: "bg-red-900", border: "border-red-500", text: "text-red-400" }, +}; + +export const CodeEdge = ({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + style = {}, + markerEnd, + data, +}: CodeEdgeProps) => { + const [isHovered, setIsHovered] = useState(false); + const { openModal } = useModalStore(); + + const [edgePath, labelX, labelY] = getBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }); + + const status: EdgeStatus = data?.status || "draft"; + const hasCode = data?.code && data.code.trim().length > 0; + const hasEventType = data?.eventType && data?.eventType.trim().length > 0; + const varsCount = data?.variables?.length || 0; + const colors = statusColors[status]; + + const handleDeleteClick = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + openModal(ModalName.deleteWorkflowEdge, { edgeId: id }); + }, + [id, openModal] + ); + + const deleteButtonClass = useMemo( + () => + cn( + "flex size-5 cursor-pointer items-center justify-center rounded-full border border-red-500 bg-gray-900 transition-all duration-200", + isHovered ? "scale-100 opacity-100" : "scale-0 opacity-0" + ), + [isHovered] + ); + + const edgeLabelContainerClass = useMemo( + () => + cn( + "flex min-w-24 flex-col items-center gap-1 rounded-lg border px-2 py-1.5 transition-all duration-200", + colors.bg, + colors.border, + isHovered ? "scale-105 shadow-lg" : "scale-100" + ), + [colors.bg, colors.border, isHovered] + ); + + const edgeStyle = useMemo( + () => ({ + ...style, + stroke: colors.stroke, + strokeWidth: isHovered ? 3 : 2, + strokeDasharray: status === "draft" ? "5,5" : undefined, + }), + [style, colors.stroke, isHovered, status] + ); + + return ( + <> + + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + stroke="transparent" + strokeWidth={20} + style={{ cursor: "pointer" }} + /> + +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + style={{ + transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`, + }} + > + + +
+ {hasEventType ? ( +
+ + {data?.eventType} +
+ ) : ( + No event + )} + +
+
+ + {hasCode ? ( + Code + ) : ( + No code + )} +
+ + {varsCount > 0 ? ( +
+ + {varsCount} +
+ ) : null} +
+
+
+
+ + ); +}; diff --git a/src/components/organisms/workflowBuilder/connectionEditorModal.tsx b/src/components/organisms/workflowBuilder/connectionEditorModal.tsx new file mode 100644 index 0000000000..97e9edb20b --- /dev/null +++ b/src/components/organisms/workflowBuilder/connectionEditorModal.tsx @@ -0,0 +1,355 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import type { Monaco } from "@monaco-editor/react"; +import { Editor } from "@monaco-editor/react"; +import * as monaco from "monaco-editor"; +import { useTranslation } from "react-i18next"; +import { LuEye, LuEyeOff, LuPlus, LuTrash2 } from "react-icons/lu"; + +import { LegacyWorkflowEdgeData, WorkflowEdgeVariable } from "@interfaces/components/workflowBuilder.interface"; +import { eventTypesPerIntegration } from "@src/constants/triggers"; +import { ModalName } from "@src/enums"; +import { Integrations } from "@src/enums/components"; +import { SelectOption } from "@src/interfaces/components"; +import { cn } from "@src/utilities"; +import { useModalStore } from "@store/useModalStore"; +import { useWorkflowBuilderStore } from "@store/useWorkflowBuilderStore"; + +import { Button, Input, Spinner, Typography } from "@components/atoms"; +import { Modal } from "@components/molecules"; +import { Select } from "@components/molecules/select"; + +const defaultCode = `def on_event(event): + # Process data from the source integration + # Use vars with: ak.get_var("key") + return event +`; + +export const ConnectionEditorModal = () => { + const { t } = useTranslation("workflowBuilder"); + const { closeModal, getModalData } = useModalStore(); + const { edges, updateEdgeCode, updateEdgeEventType, updateEdgeVariables } = useWorkflowBuilderStore(); + + const editorRef = useRef(null); + const [code, setCode] = useState(""); + const [selectedEventType, setSelectedEventType] = useState(null); + const [variables, setVariables] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [activeTab, setActiveTab] = useState<"code" | "vars">("code"); + + const modalData = getModalData<{ edgeId: string; sourceIntegration?: Integrations }>( + ModalName.connectionCodeEditor + ); + const edgeId = modalData?.edgeId; + const sourceIntegration = modalData?.sourceIntegration; + + const eventTypeOptions = useMemo(() => { + if (!sourceIntegration) return []; + const integrationKey = sourceIntegration.toLowerCase() as keyof typeof eventTypesPerIntegration; + const eventTypes = eventTypesPerIntegration[integrationKey] || []; + return eventTypes.map((eventType) => ({ + value: eventType, + label: eventType, + })); + }, [sourceIntegration]); + + useEffect(() => { + if (edgeId) { + const edge = edges.find((e) => e.id === edgeId); + const edgeData = edge?.data as LegacyWorkflowEdgeData | undefined; + const existingCode = edgeData?.code || defaultCode; + const existingEventType = edgeData?.eventType; + const existingVars = edgeData?.variables || []; + setCode(existingCode); + setVariables(existingVars); + if (existingEventType) { + setSelectedEventType({ value: existingEventType, label: existingEventType }); + } else { + setSelectedEventType(null); + } + setIsLoading(false); + } + }, [edgeId, edges]); + + const handleAddVariable = useCallback(() => { + setVariables((prev) => [...prev, { key: "", value: "", isSecret: false }]); + }, []); + + const handleRemoveVariable = useCallback((index: number) => { + setVariables((prev) => prev.filter((_, i) => i !== index)); + }, []); + + const handleVariableChange = useCallback( + (index: number, field: keyof WorkflowEdgeVariable, value: string | boolean) => { + setVariables((prev) => prev.map((v, i) => (i === index ? { ...v, [field]: value } : v))); + }, + [] + ); + + const disposeEditor = useCallback(() => { + if (editorRef.current) { + try { + editorRef.current.dispose(); + } catch { + // Ignore disposal errors + } + editorRef.current = null; + } + }, []); + + useEffect(() => { + return () => { + disposeEditor(); + }; + }, [disposeEditor]); + + const handleEditorWillMount = useCallback((monacoInstance: Monaco) => { + monacoInstance.editor.defineTheme("connectionEditorTheme", { + base: "vs-dark", + inherit: true, + rules: [], + colors: { + "editor.background": "#0a0a0a", + }, + }); + }, []); + + const handleEditorDidMount = useCallback((editor: monaco.editor.IStandaloneCodeEditor) => { + editorRef.current = editor; + editor.focus(); + }, []); + + const handleEditorChange = useCallback((value: string | undefined) => { + setCode(value || ""); + }, []); + + const handleSave = useCallback(() => { + if (edgeId) { + updateEdgeCode(edgeId, code); + if (selectedEventType) { + updateEdgeEventType(edgeId, selectedEventType.value as string); + } + const validVars = variables.filter((v) => v.key.trim().length > 0); + updateEdgeVariables(edgeId, validVars); + } + disposeEditor(); + closeModal(ModalName.connectionCodeEditor); + }, [ + edgeId, + code, + selectedEventType, + variables, + updateEdgeCode, + updateEdgeEventType, + updateEdgeVariables, + disposeEditor, + closeModal, + ]); + + const handleCancel = useCallback(() => { + disposeEditor(); + closeModal(ModalName.connectionCodeEditor); + }, [disposeEditor, closeModal]); + + return ( + +
+
+ + {t("modal.title")} + + + {t("modal.description")} + +
+
+ + {eventTypeOptions.length > 0 ? ( +
+ handleVariableChange(index, "key", e.target.value)} + placeholder="Variable name" + value={variable.key} + /> +
+ + handleVariableChange(index, "value", e.target.value) + } + placeholder="Value" + type={variable.isSecret ? "password" : "text"} + value={variable.value} + /> +
+
+
+ + +
+ + ))} + + )} + + + )} + +
+ + {t("modal.hint")} + + +
+ + +
+
+
+ ); +}; diff --git a/src/components/organisms/workflowBuilder/deleteEdgeModal.tsx b/src/components/organisms/workflowBuilder/deleteEdgeModal.tsx new file mode 100644 index 0000000000..0e087a1df6 --- /dev/null +++ b/src/components/organisms/workflowBuilder/deleteEdgeModal.tsx @@ -0,0 +1,60 @@ +import React, { useCallback } from "react"; + +import { useTranslation } from "react-i18next"; + +import { ModalName } from "@enums/components"; +import { useModalStore } from "@store/useModalStore"; +import { useWorkflowBuilderStore } from "@store/useWorkflowBuilderStore"; + +import { Button } from "@components/atoms"; +import { Modal } from "@components/molecules"; + +export const DeleteEdgeModal = () => { + const { t } = useTranslation("workflowBuilder", { keyPrefix: "deleteModal" }); + const { closeModal, getModalData } = useModalStore(); + const { removeEdge } = useWorkflowBuilderStore(); + + const modalData = getModalData<{ edgeId: string }>(ModalName.deleteWorkflowEdge); + const edgeId = modalData?.edgeId; + + const handleDelete = useCallback(() => { + if (edgeId) { + removeEdge(edgeId); + } + closeModal(ModalName.deleteWorkflowEdge); + }, [edgeId, removeEdge, closeModal]); + + const handleCancel = useCallback(() => { + closeModal(ModalName.deleteWorkflowEdge); + }, [closeModal]); + + return ( + +
+

{t("title")}

+

{t("content")}

+

{t("warning")}

+
+ +
+ + + +
+
+ ); +}; diff --git a/src/components/organisms/workflowBuilder/deleteNodeModal.tsx b/src/components/organisms/workflowBuilder/deleteNodeModal.tsx new file mode 100644 index 0000000000..570e32e699 --- /dev/null +++ b/src/components/organisms/workflowBuilder/deleteNodeModal.tsx @@ -0,0 +1,69 @@ +import React, { useCallback, useMemo } from "react"; + +import { useTranslation } from "react-i18next"; + +import { ModalName } from "@enums/components"; +import { useModalStore } from "@store/useModalStore"; +import { useWorkflowBuilderStore } from "@store/useWorkflowBuilderStore"; + +import { Button } from "@components/atoms"; +import { Modal } from "@components/molecules"; + +export const DeleteNodeModal = () => { + const { t } = useTranslation("workflowBuilder", { keyPrefix: "deleteNodeModal" }); + const { closeModal, getModalData } = useModalStore(); + const { removeNode, edges } = useWorkflowBuilderStore(); + + const modalData = getModalData<{ nodeId: string; nodeName: string }>(ModalName.deleteWorkflowNode); + const nodeId = modalData?.nodeId; + const nodeName = modalData?.nodeName; + + const connectedEdgesCount = useMemo(() => { + if (!nodeId) return 0; + return edges.filter((edge) => edge.source === nodeId || edge.target === nodeId).length; + }, [nodeId, edges]); + + const handleDelete = useCallback(() => { + if (nodeId) { + removeNode(nodeId); + } + closeModal(ModalName.deleteWorkflowNode); + }, [nodeId, removeNode, closeModal]); + + const handleCancel = useCallback(() => { + closeModal(ModalName.deleteWorkflowNode); + }, [closeModal]); + + return ( + +
+

{t("title")}

+

{t("content", { name: nodeName })}

+

{t("warning")}

+ {connectedEdgesCount > 0 ? ( +

{t("connectionWarning", { count: connectedEdgesCount })}

+ ) : null} +
+ +
+ + + +
+
+ ); +}; diff --git a/src/components/organisms/workflowBuilder/edges/dataEdge.tsx b/src/components/organisms/workflowBuilder/edges/dataEdge.tsx new file mode 100644 index 0000000000..e70130a014 --- /dev/null +++ b/src/components/organisms/workflowBuilder/edges/dataEdge.tsx @@ -0,0 +1,233 @@ +import React, { useCallback, useMemo, useState } from "react"; + +import { BaseEdge, EdgeLabelRenderer, getBezierPath, Position } from "@xyflow/react"; +import { LuArrowLeftRight, LuBookOpen, LuPencil, LuX } from "react-icons/lu"; + +import { ModalName } from "@src/enums"; +import { cn } from "@src/utilities"; +import { useModalStore } from "@store/useModalStore"; + +interface DataOperation { + operationType: "read" | "write"; + functionName: string; + lineNumber?: number; +} + +interface DataEdgeProps { + id: string; + sourceX: number; + sourceY: number; + targetX: number; + targetY: number; + sourcePosition: Position; + targetPosition: Position; + style?: React.CSSProperties; + data?: { + operations: DataOperation[]; + type: "data"; + }; +} + +export const DataEdge = ({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + style = {}, + data, +}: DataEdgeProps) => { + const [isHovered, setIsHovered] = useState(false); + const [showDetails, setShowDetails] = useState(false); + const { openModal } = useModalStore(); + + const [edgePath, labelX, labelY] = getBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }); + + const operations = data?.operations || []; + const hasOperations = operations.length > 0; + const readOps = operations.filter((op) => op.operationType === "read"); + const writeOps = operations.filter((op) => op.operationType === "write"); + + const handleDeleteClick = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + openModal(ModalName.deleteWorkflowEdge, { edgeId: id }); + }, + [id, openModal] + ); + + const edgeStyle = useMemo( + () => ({ + ...style, + stroke: hasOperations ? "#22c55e" : "#6b7280", + strokeWidth: isHovered ? 3 : 2, + strokeDasharray: hasOperations ? undefined : "5,5", + }), + [style, hasOperations, isHovered] + ); + + const labelContainerClass = useMemo( + () => + cn( + "flex flex-col items-center gap-1 rounded-lg border px-2 py-1.5 transition-all duration-200", + hasOperations ? "border-green-500/50 bg-gray-900" : "border-gray-600 bg-gray-800", + isHovered && "scale-105 shadow-lg" + ), + [hasOperations, isHovered] + ); + + const deleteButtonClass = useMemo( + () => + cn( + "absolute -right-2 -top-2 flex size-4 cursor-pointer items-center justify-center rounded-full border border-red-500 bg-gray-900 transition-all duration-200", + isHovered ? "scale-100 opacity-100" : "scale-0 opacity-0" + ), + [isHovered] + ); + + return ( + <> + + + + + + + + + + + + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + stroke="transparent" + strokeWidth={20} + style={{ cursor: "pointer" }} + /> + + +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + style={{ + transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`, + }} + > +
+ + +
setShowDetails(!showDetails)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setShowDetails(!showDetails); + } + }} + role="button" + tabIndex={0} + > +
+ + + {hasOperations ? `${operations.length} ops` : "No operations"} + +
+ + {hasOperations ? ( +
+ {readOps.length > 0 ? ( +
+ + {readOps.length} +
+ ) : null} + {writeOps.length > 0 ? ( +
+ + {writeOps.length} +
+ ) : null} +
+ ) : null} +
+ + {showDetails && hasOperations ? ( +
+ {readOps.length > 0 ? ( +
+
+ + Read Operations +
+ {readOps.map((op, index) => ( +
+ • {op.functionName}() + {op.lineNumber ? ( + :L{op.lineNumber} + ) : null} +
+ ))} +
+ ) : null} + {writeOps.length > 0 ? ( +
+
+ + Write Operations +
+ {writeOps.map((op, index) => ( +
+ • {op.functionName}() + {op.lineNumber ? ( + :L{op.lineNumber} + ) : null} +
+ ))} +
+ ) : null} +
+ ) : null} +
+
+
+ + ); +}; diff --git a/src/components/organisms/workflowBuilder/edges/executionEdge.tsx b/src/components/organisms/workflowBuilder/edges/executionEdge.tsx new file mode 100644 index 0000000000..a8a4a3d935 --- /dev/null +++ b/src/components/organisms/workflowBuilder/edges/executionEdge.tsx @@ -0,0 +1,128 @@ +import React, { useMemo, useState } from "react"; + +import { BaseEdge, EdgeLabelRenderer, getBezierPath, Position } from "@xyflow/react"; +import { LuPlay } from "react-icons/lu"; + +import { cn } from "@src/utilities"; + +interface ExecutionEdgeProps { + id: string; + sourceX: number; + sourceY: number; + targetX: number; + targetY: number; + sourcePosition: Position; + targetPosition: Position; + style?: React.CSSProperties; + markerEnd?: string; + data?: { + functionCall: string; + isActive: boolean; + type: "execution"; + }; +} + +export const ExecutionEdge = ({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + style = {}, + markerEnd, + data, +}: ExecutionEdgeProps) => { + const [isHovered, setIsHovered] = useState(false); + + const [edgePath, labelX, labelY] = getBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }); + + const isActive = data?.isActive ?? false; + const functionCall = data?.functionCall || "undefined"; + + const edgeStyle = useMemo( + () => ({ + ...style, + stroke: isActive ? "#f59e0b" : "#6b7280", + strokeWidth: isHovered ? 4 : 3, + filter: isActive ? "drop-shadow(0 0 6px rgba(245, 158, 11, 0.5))" : undefined, + }), + [style, isActive, isHovered] + ); + + const labelContainerClass = useMemo( + () => + cn( + "flex items-center gap-1.5 rounded-full border px-3 py-1.5 transition-all duration-200", + isActive + ? "border-amber-500/50 bg-amber-900/40 shadow-lg shadow-amber-500/20" + : "border-gray-600 bg-gray-800", + isHovered && "scale-105" + ), + [isActive, isHovered] + ); + + return ( + <> + + + + + + + + + + {isActive ? ( + + + + ) : null} + + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + stroke="transparent" + strokeWidth={20} + style={{ cursor: "pointer" }} + /> + + +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + style={{ + transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`, + }} + > +
+ + + {functionCall}() + +
+
+
+ + ); +}; diff --git a/src/components/organisms/workflowBuilder/edges/index.ts b/src/components/organisms/workflowBuilder/edges/index.ts new file mode 100644 index 0000000000..41866e9148 --- /dev/null +++ b/src/components/organisms/workflowBuilder/edges/index.ts @@ -0,0 +1,2 @@ +export { DataEdge } from "./dataEdge"; +export { ExecutionEdge } from "./executionEdge"; diff --git a/src/components/organisms/workflowBuilder/index.ts b/src/components/organisms/workflowBuilder/index.ts new file mode 100644 index 0000000000..fec92c6bee --- /dev/null +++ b/src/components/organisms/workflowBuilder/index.ts @@ -0,0 +1,15 @@ +export { CodeEdge } from "./codeEdge"; +export { ConnectionEditorModal } from "./connectionEditorModal"; +export { DeleteEdgeModal } from "./deleteEdgeModal"; +export { DeleteNodeModal } from "./deleteNodeModal"; +export { DataEdge, ExecutionEdge } from "./edges"; +export { IntegrationNode } from "./integrationNode"; +export { IntegrationsSidebar } from "./integrationsSidebar"; +export { ConnectionConfigModal, TriggerConfigModal } from "./modals"; +export { CodeNode, ConnectionNode, TriggerNode } from "./nodes"; +export { WorkflowSidebar } from "./sidebar"; +export { WorkflowBuilder } from "./workflowBuilder"; +export { WorkflowBuilderError } from "./workflowBuilderError"; +export { WorkflowBuilderSkeleton } from "./workflowBuilderSkeleton"; +export { WorkflowBuilderWarnings } from "./workflowBuilderWarnings"; +export { WorkflowCanvas } from "./workflowCanvas"; diff --git a/src/components/organisms/workflowBuilder/integrationNode.tsx b/src/components/organisms/workflowBuilder/integrationNode.tsx new file mode 100644 index 0000000000..6a19d73652 --- /dev/null +++ b/src/components/organisms/workflowBuilder/integrationNode.tsx @@ -0,0 +1,85 @@ +import React, { memo, useCallback, useMemo, useState } from "react"; + +import { Handle, Position, NodeProps, Node } from "@xyflow/react"; +import { LuX, LuZap } from "react-icons/lu"; + +import { IntegrationNodeData } from "@interfaces/components/workflowBuilder.interface"; +import { ModalName } from "@src/enums"; +import { fitleredIntegrationsMap, Integrations } from "@src/enums/components"; +import { cn } from "@src/utilities"; +import { useModalStore } from "@store/useModalStore"; +import { useWorkflowBuilderStore } from "@store/useWorkflowBuilderStore"; + +import { IconSvg, Typography } from "@components/atoms"; + +type IntegrationNodeProps = NodeProps>; + +const IntegrationNodeComponent = ({ id, data }: IntegrationNodeProps) => { + const [isHovered, setIsHovered] = useState(false); + const { openModal } = useModalStore(); + const { triggerNodeId } = useWorkflowBuilderStore(); + const integration = fitleredIntegrationsMap[data.integration as Integrations]; + const icon = integration?.icon; + const isTrigger = triggerNodeId === id; + + const handleDeleteClick = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + openModal(ModalName.deleteWorkflowNode, { nodeId: id, nodeName: data.label }); + }, + [id, data.label, openModal] + ); + + const deleteButtonClass = useMemo( + () => + cn( + "absolute right-1 top-1 flex size-4 cursor-pointer items-center justify-center rounded-full border border-red-500 bg-gray-900 transition-all duration-200", + isHovered ? "scale-100 opacity-100" : "scale-0 opacity-0" + ), + [isHovered] + ); + + const nodeContainerClass = useMemo( + () => + cn( + "relative flex min-w-28 flex-col items-center rounded-lg border bg-gray-950 p-3 shadow-lg transition-all duration-200", + isTrigger ? "border-yellow-500 shadow-yellow-500/20" : "border-gray-600" + ), + [isTrigger] + ); + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {isTrigger ? ( +
+ + TRIGGER +
+ ) : null} + + +
+ {icon ? : null} +
+ + {data.label} + + + + +
+ ); +}; + +export const IntegrationNode = memo(IntegrationNodeComponent); diff --git a/src/components/organisms/workflowBuilder/integrationsSidebar.tsx b/src/components/organisms/workflowBuilder/integrationsSidebar.tsx new file mode 100644 index 0000000000..c1253768cc --- /dev/null +++ b/src/components/organisms/workflowBuilder/integrationsSidebar.tsx @@ -0,0 +1,65 @@ +import React, { DragEvent } from "react"; + +import { useTranslation } from "react-i18next"; + +import { fitleredIntegrationsMap, Integrations } from "@src/enums/components"; +import { IntegrationSelectOption } from "@src/interfaces/components/forms"; + +import { IconSvg, Typography } from "@components/atoms"; + +interface DraggableIntegrationProps { + integration: IntegrationSelectOption; + onDragStart: (event: DragEvent, integration: IntegrationSelectOption) => void; +} + +const DraggableIntegration = ({ integration, onDragStart }: DraggableIntegrationProps) => { + return ( +
onDragStart(event, integration)} + > +
+ +
+ + {integration.label} + +
+ ); +}; + +export const IntegrationsSidebar = () => { + const { t } = useTranslation("workflowBuilder"); + + const integrations = Object.values(fitleredIntegrationsMap).sort((a, b) => a.label.localeCompare(b.label)); + + const handleDragStart = (event: DragEvent, integration: IntegrationSelectOption) => { + event.dataTransfer.setData("application/reactflow", JSON.stringify(integration)); + event.dataTransfer.effectAllowed = "move"; + }; + + return ( + + ); +}; diff --git a/src/components/organisms/workflowBuilder/modals/connectionConfigModal.tsx b/src/components/organisms/workflowBuilder/modals/connectionConfigModal.tsx new file mode 100644 index 0000000000..633095cb02 --- /dev/null +++ b/src/components/organisms/workflowBuilder/modals/connectionConfigModal.tsx @@ -0,0 +1,225 @@ +import React, { useCallback, useEffect, useState } from "react"; + +import { LuCheck, LuLoader, LuX } from "react-icons/lu"; + +import { ConnectionNodeData, ConnectionStatus } from "@interfaces/components/workflowBuilder.interface"; +import { ModalName } from "@src/enums"; +import { fitleredIntegrationsMap, Integrations } from "@src/enums/components"; +import { cn } from "@src/utilities"; +import { useModalStore } from "@store/useModalStore"; +import { useWorkflowBuilderStore } from "@store/useWorkflowBuilderStore"; + +import { Button, IconSvg, Input, Typography } from "@components/atoms"; +import { Modal } from "@components/molecules"; + +const statusConfig: Record = { + disconnected: { label: "Disconnected", color: "text-gray-400", icon: null }, + connected: { label: "Connected", color: "text-green-400", icon: LuCheck }, + active: { label: "Active", color: "text-blue-400", icon: LuCheck }, + error: { label: "Error", color: "text-red-400", icon: LuX }, +}; + +export const ConnectionConfigModal = () => { + const { closeModal, getModalData } = useModalStore(); + const { nodes, updateNode, getCodeNodes } = useWorkflowBuilderStore(); + + const modalData = getModalData<{ connectionName: string; nodeId: string }>(ModalName.connectionConfig); + const nodeId = modalData?.nodeId; + + const currentNode = nodes.find((n) => n.id === nodeId && n.type === "connection"); + const nodeData = currentNode?.data as ConnectionNodeData | undefined; + + const integration = nodeData ? fitleredIntegrationsMap[nodeData.integration as Integrations] : null; + + const codeNodes = getCodeNodes(); + const usedByFunctions = codeNodes.flatMap((node) => + node.data.usedConnections.includes(nodeData?.connectionName || "") + ? node.data.entryPoints.map((ep) => ep.name) + : [] + ); + + const [connectionName, setConnectionName] = useState(nodeData?.connectionName || ""); + const [status, setStatus] = useState(nodeData?.status || "disconnected"); + const [isTesting, setIsTesting] = useState(false); + + useEffect(() => { + if (nodeData) { + setConnectionName(nodeData.connectionName); + setStatus(nodeData.status); + } + }, [nodeData]); + + const handleTestConnection = useCallback(async () => { + setIsTesting(true); + await new Promise((resolve) => setTimeout(resolve, 1500)); + setStatus("connected"); + setIsTesting(false); + }, []); + + const handleSave = useCallback(() => { + if (!nodeId) return; + + const updatedData: Partial = { + connectionName, + status, + }; + + updateNode(nodeId, updatedData); + closeModal(ModalName.connectionConfig); + }, [nodeId, connectionName, status, updateNode, closeModal]); + + const handleCancel = useCallback(() => { + closeModal(ModalName.connectionConfig); + }, [closeModal]); + + const handleDisconnect = useCallback(() => { + setStatus("disconnected"); + }, []); + + const statusInfo = statusConfig[status]; + const StatusIcon = statusInfo.icon; + + return ( + +
+
+ {integration?.icon ? ( +
+ +
+ ) : null} +
+ + Configure Connection + + + {nodeData?.displayName || "Unknown Integration"} + +
+
+ +
+
+ + Connection Name + + setConnectionName(e.target.value)} + placeholder="my_connection" + value={connectionName} + /> + + Use this name to reference the connection in your code + +
+ +
+
+
+ + Status + +
+ {StatusIcon ? ( + + ) : ( + + )} + + {statusInfo.label} + +
+
+
+ {status === "connected" || status === "active" ? ( + + ) : null} + +
+
+
+ + {status === "disconnected" ? ( +
+ + Authentication Required + + + This connection needs to be authenticated before it can be used. Click the button below + to set up authentication. + + +
+ ) : null} + + {usedByFunctions.length > 0 ? ( +
+ + Used in Code + +
+ {usedByFunctions.map((fn) => ( +
+ + + {fn}() + +
+ ))} +
+
+ ) : ( +
+ + This connection is not used in any code files yet + +
+ )} +
+ +
+ + +
+
+
+ ); +}; diff --git a/src/components/organisms/workflowBuilder/modals/index.ts b/src/components/organisms/workflowBuilder/modals/index.ts new file mode 100644 index 0000000000..49213b3f2c --- /dev/null +++ b/src/components/organisms/workflowBuilder/modals/index.ts @@ -0,0 +1,2 @@ +export { ConnectionConfigModal } from "./connectionConfigModal"; +export { TriggerConfigModal } from "./triggerConfigModal"; diff --git a/src/components/organisms/workflowBuilder/modals/triggerConfigModal.tsx b/src/components/organisms/workflowBuilder/modals/triggerConfigModal.tsx new file mode 100644 index 0000000000..a26d7b8e1a --- /dev/null +++ b/src/components/organisms/workflowBuilder/modals/triggerConfigModal.tsx @@ -0,0 +1,285 @@ +import React, { useCallback, useEffect, useState } from "react"; + +import { LuClock, LuLink, LuZap } from "react-icons/lu"; + +import { TriggerNodeData, TriggerType } from "@interfaces/components/workflowBuilder.interface"; +import { ModalName } from "@src/enums"; +import { cn } from "@src/utilities"; +import { useModalStore } from "@store/useModalStore"; +import { useWorkflowBuilderStore } from "@store/useWorkflowBuilderStore"; + +import { Button, Input, Typography } from "@components/atoms"; +import { Modal } from "@components/molecules"; + +const triggerTypes: { color: string; icon: React.ElementType; label: string; type: TriggerType }[] = [ + { type: "schedule", label: "Schedule", icon: LuClock, color: "text-amber-400" }, + { type: "webhook", label: "Webhook", icon: LuLink, color: "text-purple-400" }, + { type: "event", label: "Event", icon: LuZap, color: "text-blue-400" }, +]; + +const cronPresets = [ + { label: "Every minute", value: "* * * * *" }, + { label: "Every hour", value: "0 * * * *" }, + { label: "Daily at midnight", value: "0 0 * * *" }, + { label: "Weekly (Sunday)", value: "0 0 * * 0" }, + { label: "Bi-weekly", value: "0 6 * * 0" }, + { label: "Monthly", value: "0 0 1 * *" }, +]; + +const httpMethods = ["GET", "POST", "PUT", "DELETE"] as const; + +export const TriggerConfigModal = () => { + const { closeModal, getModalData } = useModalStore(); + const { nodes, updateNode, getCodeNodes } = useWorkflowBuilderStore(); + + const modalData = getModalData<{ nodeId: string }>(ModalName.triggerConfig); + const nodeId = modalData?.nodeId; + + const currentNode = nodes.find((n) => n.id === nodeId && n.type === "trigger"); + const nodeData = currentNode?.data as TriggerNodeData | undefined; + + const codeNodes = getCodeNodes(); + const entryPoints = codeNodes.flatMap((node) => + node.data.entryPoints.map((ep) => ({ + value: `${node.data.fileName}:${ep.name}`, + label: `${node.data.fileName}:${ep.name}()`, + })) + ); + + const [triggerType, setTriggerType] = useState(nodeData?.type || "schedule"); + const [name, setName] = useState(nodeData?.name || ""); + const [schedule, setSchedule] = useState(nodeData?.schedule || "0 * * * *"); + const [webhookPath, setWebhookPath] = useState(nodeData?.webhookPath || "/webhook"); + const [httpMethod, setHttpMethod] = useState<(typeof httpMethods)[number]>(nodeData?.httpMethod || "POST"); + const [eventType, setEventType] = useState(nodeData?.eventType || ""); + const [call, setCall] = useState(nodeData?.call || ""); + const [isDurable, setIsDurable] = useState(nodeData?.isDurable ?? true); + + useEffect(() => { + if (nodeData) { + setTriggerType(nodeData.type); + setName(nodeData.name); + setSchedule(nodeData.schedule || "0 * * * *"); + setWebhookPath(nodeData.webhookPath || "/webhook"); + setHttpMethod(nodeData.httpMethod || "POST"); + setEventType(nodeData.eventType || ""); + setCall(nodeData.call); + setIsDurable(nodeData.isDurable); + } + }, [nodeData]); + + const handleSave = useCallback(() => { + if (!nodeId) return; + + const updatedData: Partial = { + type: triggerType, + name: name || `${triggerType}_trigger`, + call, + isDurable, + status: call ? "configured" : "draft", + }; + + if (triggerType === "schedule") { + updatedData.schedule = schedule; + } else if (triggerType === "webhook") { + updatedData.webhookPath = webhookPath; + updatedData.httpMethod = httpMethod; + } else if (triggerType === "event") { + updatedData.eventType = eventType; + } + + updateNode(nodeId, updatedData); + closeModal(ModalName.triggerConfig); + }, [ + nodeId, + triggerType, + name, + schedule, + webhookPath, + httpMethod, + eventType, + call, + isDurable, + updateNode, + closeModal, + ]); + + const handleCancel = useCallback(() => { + closeModal(ModalName.triggerConfig); + }, [closeModal]); + + return ( + +
+ + Configure Trigger + + +
+
+ + Trigger Name + + setName(e.target.value)} placeholder="my_trigger" value={name} /> +
+ +
+ + Trigger Type + +
+ {triggerTypes.map((t) => { + const Icon = t.icon; + return ( + + ); + })} +
+
+ + {triggerType === "schedule" ? ( +
+ + Cron Expression + + setSchedule(e.target.value)} + placeholder="0 * * * *" + value={schedule} + /> +
+ {cronPresets.map((preset) => ( + + ))} +
+
+ ) : null} + + {triggerType === "webhook" ? ( + <> +
+ + HTTP Method + +
+ {httpMethods.map((method) => ( + + ))} +
+
+
+ + Webhook Path + + setWebhookPath(e.target.value)} + placeholder="/webhook/my-endpoint" + value={webhookPath} + /> +
+ + ) : null} + + {triggerType === "event" ? ( +
+ + Event Type + + setEventType(e.target.value)} + placeholder="slack_message_received" + value={eventType} + /> +
+ ) : null} + +
+ + Entry Point (function to call) + + {entryPoints.length > 0 ? ( + + ) : ( + setCall(e.target.value)} + placeholder="program.py:main" + value={call} + /> + )} +
+ +
+ setIsDurable(e.target.checked)} + type="checkbox" + /> + +
+
+ +
+ + +
+
+
+ ); +}; diff --git a/src/components/organisms/workflowBuilder/nodes/codeNode.tsx b/src/components/organisms/workflowBuilder/nodes/codeNode.tsx new file mode 100644 index 0000000000..03ff68bdcb --- /dev/null +++ b/src/components/organisms/workflowBuilder/nodes/codeNode.tsx @@ -0,0 +1,204 @@ +import React, { memo, useCallback, useMemo, useState } from "react"; + +import { Handle, Position, NodeProps, Node } from "@xyflow/react"; +import { LuCode, LuExternalLink, LuPlay, LuPlug, LuX } from "react-icons/lu"; + +import { CodeNodeData } from "@interfaces/components/workflowBuilder.interface"; +import { ModalName } from "@src/enums"; +import { cn } from "@src/utilities"; +import { useModalStore } from "@store/useModalStore"; +import { useWorkflowBuilderStore } from "@store/useWorkflowBuilderStore"; + +import { Typography } from "@components/atoms"; + +type CodeNodeProps = NodeProps>; + +const statusColors: Record = { + draft: { border: "border-gray-600", bg: "bg-gray-900", iconBg: "bg-gray-700" }, + configured: { border: "border-green-500/50", bg: "bg-gray-900", iconBg: "bg-green-900/30" }, + active: { border: "border-blue-500", bg: "bg-gray-900", iconBg: "bg-blue-900/30" }, + error: { border: "border-red-500", bg: "bg-gray-900", iconBg: "bg-red-900/30" }, +}; + +const languageIcons: Record = { + python: { color: "text-yellow-400", label: "Python" }, + starlark: { color: "text-blue-400", label: "Starlark" }, +}; + +const CodeNodeComponent = ({ id, data, selected }: CodeNodeProps) => { + const [isHovered, setIsHovered] = useState(false); + const { openModal } = useModalStore(); + const { setSelectedNodeId } = useWorkflowBuilderStore(); + + const status = statusColors[data.status] || statusColors.draft; + const langConfig = languageIcons[data.language] || languageIcons.python; + + const handleDeleteClick = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + openModal(ModalName.deleteWorkflowNode, { nodeId: id, nodeName: data.fileName }); + }, + [id, data.fileName, openModal] + ); + + const handleNodeClick = useCallback(() => { + setSelectedNodeId(id); + }, [id, setSelectedNodeId]); + + const handleViewCode = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + openModal(ModalName.codeEditor, { filePath: data.filePath }); + }, + [data.filePath, openModal] + ); + + const deleteButtonClass = useMemo( + () => + cn( + "absolute -right-2 -top-2 z-10 flex size-5 cursor-pointer items-center justify-center rounded-full border border-red-500 bg-gray-900 transition-all duration-200", + isHovered ? "scale-100 opacity-100" : "scale-0 opacity-0" + ), + [isHovered] + ); + + const containerClass = useMemo( + () => + cn( + "relative min-w-[200px] max-w-280 rounded-xl border-2 p-4 shadow-lg transition-all duration-200", + status.border, + status.bg, + selected && "ring-2 ring-blue-500 ring-offset-2 ring-offset-gray-950", + isHovered && "shadow-xl" + ), + [status.border, status.bg, selected, isHovered] + ); + + const activeEntryPoints = data.entryPoints.filter((ep) => ep.isActive); + const inactiveEntryPoints = data.entryPoints.filter((ep) => !ep.isActive); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handleNodeClick(); + } + }, + [handleNodeClick] + ); + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + role="button" + tabIndex={0} + > + + + + +
+
+
+ +
+
+ + {data.fileName} + + + {langConfig.label} + +
+
+ +
+ + {activeEntryPoints.length > 0 || inactiveEntryPoints.length > 0 ? ( +
+ + + Entry Points + +
+ {activeEntryPoints.map((ep) => ( +
+ + + {ep.name}() + +
+ ))} + {inactiveEntryPoints.slice(0, 3).map((ep) => ( +
+ + + {ep.name}() + +
+ ))} + {inactiveEntryPoints.length > 3 ? ( + + +{inactiveEntryPoints.length - 3} more + + ) : null} +
+
+ ) : null} + + {data.usedConnections.length > 0 ? ( +
+ + + Connections ({data.usedConnections.length}) + +
+ {data.usedConnections.map((conn) => ( + + {conn} + + ))} +
+
+ ) : null} + + + + +
+ ); +}; + +export const CodeNode = memo(CodeNodeComponent); diff --git a/src/components/organisms/workflowBuilder/nodes/connectionNode.tsx b/src/components/organisms/workflowBuilder/nodes/connectionNode.tsx new file mode 100644 index 0000000000..ea82b395c5 --- /dev/null +++ b/src/components/organisms/workflowBuilder/nodes/connectionNode.tsx @@ -0,0 +1,223 @@ +import React, { memo, useCallback, useMemo, useState } from "react"; + +import { Handle, Position, NodeProps, Node } from "@xyflow/react"; +import { LuCheck, LuX, LuZap } from "react-icons/lu"; + +import { ConnectionNodeData, ConnectionStatus } from "@interfaces/components/workflowBuilder.interface"; +import { ModalName } from "@src/enums"; +import { fitleredIntegrationsMap, Integrations } from "@src/enums/components"; +import { cn } from "@src/utilities"; +import { useModalStore } from "@store/useModalStore"; +import { useWorkflowBuilderStore } from "@store/useWorkflowBuilderStore"; + +import { IconSvg, Typography } from "@components/atoms"; + +type ConnectionNodeProps = NodeProps>; + +const statusConfig: Record< + ConnectionStatus, + { bg: string; border: string; icon: React.ElementType | null; iconColor: string; pulse: boolean } +> = { + disconnected: { + border: "border-gray-600 border-dashed", + bg: "bg-gray-900", + icon: null, + iconColor: "", + pulse: false, + }, + connected: { + border: "border-green-500", + bg: "bg-gray-900", + icon: LuCheck, + iconColor: "text-green-500", + pulse: false, + }, + active: { + border: "border-blue-500", + bg: "bg-gray-900", + icon: LuZap, + iconColor: "text-blue-500", + pulse: true, + }, + error: { + border: "border-red-500", + bg: "bg-gray-900", + icon: LuX, + iconColor: "text-red-500", + pulse: false, + }, +}; + +const ConnectionNodeComponent = ({ id, data, selected }: ConnectionNodeProps) => { + const [isHovered, setIsHovered] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); + const { openModal } = useModalStore(); + const { setSelectedNodeId } = useWorkflowBuilderStore(); + + const integration = fitleredIntegrationsMap[data.integration as Integrations]; + const icon = integration?.icon; + const statusStyle = statusConfig[data.status] || statusConfig.disconnected; + const StatusIcon = statusStyle.icon; + + const handleDeleteClick = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + openModal(ModalName.deleteWorkflowNode, { nodeId: id, nodeName: data.connectionName }); + }, + [id, data.connectionName, openModal] + ); + + const handleNodeClick = useCallback(() => { + setSelectedNodeId(id); + openModal(ModalName.connectionConfig, { nodeId: id, connectionName: data.connectionName }); + }, [id, data.connectionName, setSelectedNodeId, openModal]); + + const handleExpandToggle = useCallback((event: React.MouseEvent) => { + event.stopPropagation(); + setIsExpanded((prev) => !prev); + }, []); + + const deleteButtonClass = useMemo( + () => + cn( + "absolute -right-1 -top-1 z-10 flex size-4 cursor-pointer items-center justify-center rounded-full border border-red-500 bg-gray-900 transition-all duration-200", + isHovered ? "scale-100 opacity-100" : "scale-0 opacity-0" + ), + [isHovered] + ); + + const containerClass = useMemo( + () => + cn( + "relative flex flex-col items-center transition-all duration-200", + isExpanded ? "min-w-[180px]" : "w-[100px]" + ), + [isExpanded] + ); + + const circleClass = useMemo( + () => + cn( + "relative flex size-16 cursor-pointer items-center justify-center rounded-full border-2 transition-all duration-200", + statusStyle.border, + statusStyle.bg, + selected && "ring-2 ring-blue-500 ring-offset-2 ring-offset-gray-950", + isHovered && "scale-105 shadow-lg", + statusStyle.pulse && "animate-pulse" + ), + [statusStyle.border, statusStyle.bg, statusStyle.pulse, selected, isHovered] + ); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handleNodeClick(); + } + }, + [handleNodeClick] + ); + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + role="button" + tabIndex={0} + > + + + + +
+ {icon ? ( +
+ +
+ ) : ( + + {data.integration.slice(0, 2).toUpperCase()} + + )} + + {StatusIcon ? ( +
+ +
+ ) : null} +
+ + + {data.connectionName} + + + {data.displayName} + + + {isExpanded && data.usedByFunctions.length > 0 ? ( +
+ + Used by: + +
+ {data.usedByFunctions.slice(0, 4).map((fn) => ( + + • {fn}() + + ))} + {data.usedByFunctions.length > 4 ? ( + + +{data.usedByFunctions.length - 4} more + + ) : null} +
+
+ ) : null} + + {isHovered && data.usedByFunctions.length > 0 && !isExpanded ? ( + + ) : null} + + + + +
+ ); +}; + +export const ConnectionNode = memo(ConnectionNodeComponent); diff --git a/src/components/organisms/workflowBuilder/nodes/index.ts b/src/components/organisms/workflowBuilder/nodes/index.ts new file mode 100644 index 0000000000..ac199735b3 --- /dev/null +++ b/src/components/organisms/workflowBuilder/nodes/index.ts @@ -0,0 +1,3 @@ +export { CodeNode } from "./codeNode"; +export { ConnectionNode } from "./connectionNode"; +export { TriggerNode } from "./triggerNode"; diff --git a/src/components/organisms/workflowBuilder/nodes/triggerNode.tsx b/src/components/organisms/workflowBuilder/nodes/triggerNode.tsx new file mode 100644 index 0000000000..d1baca0de6 --- /dev/null +++ b/src/components/organisms/workflowBuilder/nodes/triggerNode.tsx @@ -0,0 +1,162 @@ +import React, { memo, useCallback, useMemo, useState } from "react"; + +import { Handle, Position, NodeProps, Node } from "@xyflow/react"; +import { LuClock, LuLink, LuX, LuZap } from "react-icons/lu"; + +import { TriggerNodeData, TriggerType } from "@interfaces/components/workflowBuilder.interface"; +import { ModalName } from "@src/enums"; +import { cn } from "@src/utilities"; +import { useModalStore } from "@store/useModalStore"; +import { useWorkflowBuilderStore } from "@store/useWorkflowBuilderStore"; + +import { Typography } from "@components/atoms"; + +type TriggerNodeProps = NodeProps>; + +const triggerConfig: Record = { + schedule: { + icon: LuClock, + color: "text-amber-400", + bgColor: "bg-amber-500/20", + label: "Schedule", + }, + webhook: { + icon: LuLink, + color: "text-purple-400", + bgColor: "bg-purple-500/20", + label: "Webhook", + }, + event: { + icon: LuZap, + color: "text-blue-400", + bgColor: "bg-blue-500/20", + label: "Event", + }, +}; + +const statusColors: Record = { + draft: { border: "border-gray-500", shadow: "" }, + configured: { border: "border-green-500", shadow: "shadow-green-500/20" }, + active: { border: "border-blue-500", shadow: "shadow-blue-500/30" }, + error: { border: "border-red-500", shadow: "shadow-red-500/20" }, +}; + +const TriggerNodeComponent = ({ id, data, selected }: TriggerNodeProps) => { + const [isHovered, setIsHovered] = useState(false); + const { openModal } = useModalStore(); + const { setSelectedNodeId } = useWorkflowBuilderStore(); + + const config = triggerConfig[data.type]; + const Icon = config.icon; + const status = statusColors[data.status] || statusColors.draft; + + const handleDeleteClick = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + openModal(ModalName.deleteWorkflowNode, { nodeId: id, nodeName: data.name }); + }, + [id, data.name, openModal] + ); + + const handleNodeClick = useCallback(() => { + setSelectedNodeId(id); + openModal(ModalName.triggerConfig, { nodeId: id }); + }, [id, setSelectedNodeId, openModal]); + + const deleteButtonClass = useMemo( + () => + cn( + "absolute -right-2 -top-2 z-10 flex size-5 cursor-pointer items-center justify-center rounded-full border border-red-500 bg-gray-900 transition-all duration-200", + isHovered ? "scale-100 opacity-100" : "scale-0 opacity-0" + ), + [isHovered] + ); + + const displayValue = useMemo(() => { + if (data.type === "schedule" && data.schedule) { + return data.schedule; + } + if (data.type === "webhook" && data.httpMethod) { + return data.httpMethod; + } + if (data.type === "event" && data.eventType) { + return data.eventType; + } + return "Configure..."; + }, [data]); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handleNodeClick(); + } + }, + [handleNodeClick] + ); + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + role="button" + tabIndex={0} + > + + + + + + +
+
+ +
+ + + {data.name || config.label} + + + + {displayValue} + + + {data.call ? ( + + → {data.call.split(":")[1] || data.call} + + ) : null} +
+ + +
+ ); +}; + +export const TriggerNode = memo(TriggerNodeComponent); diff --git a/src/components/organisms/workflowBuilder/sidebar/codeFilesSection.tsx b/src/components/organisms/workflowBuilder/sidebar/codeFilesSection.tsx new file mode 100644 index 0000000000..4e7317b921 --- /dev/null +++ b/src/components/organisms/workflowBuilder/sidebar/codeFilesSection.tsx @@ -0,0 +1,190 @@ +import React, { DragEvent, useState } from "react"; + +import { LuChevronDown, LuChevronRight, LuCode, LuPlay, LuPlus } from "react-icons/lu"; + +import { EntryPoint } from "@interfaces/components/workflowBuilder.interface"; +import { cn } from "@src/utilities"; +import { useWorkflowBuilderStore } from "@store/useWorkflowBuilderStore"; + +import { Typography } from "@components/atoms"; + +interface CodeFile { + fileName: string; + filePath: string; + language: "python" | "starlark"; + entryPoints: EntryPoint[]; +} + +interface CodeFileItemProps { + file: CodeFile; + onDragStart: (event: DragEvent, file: CodeFile) => void; +} + +const languageColors: Record = { + python: { text: "text-yellow-400", bg: "bg-yellow-500/20" }, + starlark: { text: "text-blue-400", bg: "bg-blue-500/20" }, +}; + +const CodeFileItem = ({ file, onDragStart }: CodeFileItemProps) => { + const [isExpanded, setIsExpanded] = useState(false); + const langColors = languageColors[file.language] || languageColors.python; + const activeEntryPoints = file.entryPoints.filter((ep) => ep.isActive); + + return ( +
+
onDragStart(event, file)} + > + +
+ +
+
+ + {file.fileName} + +
+ {activeEntryPoints.length > 0 ? ( + + {activeEntryPoints.length} active + + ) : null} +
+ + {isExpanded && file.entryPoints.length > 0 ? ( +
+ + + Entry Points + +
+ {file.entryPoints.map((ep) => ( +
+ + + {ep.name}() + + {ep.lineNumber ? ( + + :L{ep.lineNumber} + + ) : null} +
+ ))} +
+
+ ) : null} +
+ ); +}; + +export const CodeFilesSection = () => { + const [isExpanded, setIsExpanded] = useState(true); + const { getCodeNodes } = useWorkflowBuilderStore(); + + const codeNodes = getCodeNodes(); + const codeFiles: CodeFile[] = codeNodes.map((node) => ({ + fileName: node.data.fileName, + filePath: node.data.filePath, + language: node.data.language, + entryPoints: node.data.entryPoints, + })); + + const defaultFiles: CodeFile[] = + codeFiles.length === 0 + ? [ + { + fileName: "program.py", + filePath: "program.py", + language: "python", + entryPoints: [ + { name: "on_event", lineNumber: 1, isActive: false }, + { name: "main", lineNumber: 10, isActive: false }, + ], + }, + ] + : []; + + const allFiles = [...codeFiles, ...defaultFiles]; + + const handleDragStart = (event: DragEvent, file: CodeFile) => { + const dragData = { + nodeType: "code", + fileName: file.fileName, + filePath: file.filePath, + language: file.language, + entryPoints: file.entryPoints, + }; + event.dataTransfer.setData("application/workflow-node", JSON.stringify(dragData)); + event.dataTransfer.effectAllowed = "move"; + }; + + return ( +
+ + + {isExpanded ? ( +
+ {allFiles.map((file) => ( + + ))} + + +
+ ) : null} +
+ ); +}; diff --git a/src/components/organisms/workflowBuilder/sidebar/connectionsSection.tsx b/src/components/organisms/workflowBuilder/sidebar/connectionsSection.tsx new file mode 100644 index 0000000000..8fdbe5b751 --- /dev/null +++ b/src/components/organisms/workflowBuilder/sidebar/connectionsSection.tsx @@ -0,0 +1,191 @@ +import React, { DragEvent, useState } from "react"; + +import { LuChevronDown, LuPlug, LuPlus } from "react-icons/lu"; + +import { fitleredIntegrationsMap, Integrations } from "@src/enums/components"; +import { IntegrationSelectOption } from "@src/interfaces/components/forms"; +import { cn } from "@src/utilities"; +import { useWorkflowBuilderStore } from "@store/useWorkflowBuilderStore"; + +import { IconSvg, Typography } from "@components/atoms"; + +interface ProjectConnection { + name: string; + integration: Integrations; + displayName: string; +} + +interface DraggableConnectionProps { + connection: ProjectConnection; + onDragStart: (event: DragEvent, connection: ProjectConnection) => void; +} + +const DraggableConnection = ({ connection, onDragStart }: DraggableConnectionProps) => { + const integration = fitleredIntegrationsMap[connection.integration]; + const icon = integration?.icon; + + return ( +
onDragStart(event, connection)} + > +
+ {icon ? : null} +
+
+ + {connection.name} + + + {connection.displayName} + +
+ +
+ ); +}; + +interface DraggableIntegrationProps { + integration: IntegrationSelectOption; + onDragStart: (event: DragEvent, integration: IntegrationSelectOption) => void; +} + +const DraggableIntegration = ({ integration, onDragStart }: DraggableIntegrationProps) => { + return ( +
onDragStart(event, integration)} + > +
+ +
+ + {integration.label} + +
+ ); +}; + +export const ConnectionsSection = () => { + const [isExpanded, setIsExpanded] = useState(true); + const [showAvailable, setShowAvailable] = useState(false); + const { getConnectionNodes } = useWorkflowBuilderStore(); + + const connectionNodes = getConnectionNodes(); + const projectConnections: ProjectConnection[] = connectionNodes.map((node) => ({ + name: node.data.connectionName, + integration: node.data.integration, + displayName: node.data.displayName, + })); + + const availableIntegrations = Object.values(fitleredIntegrationsMap).sort((a, b) => a.label.localeCompare(b.label)); + + const handleConnectionDragStart = (event: DragEvent, connection: ProjectConnection) => { + const dragData = { + nodeType: "connection", + connectionName: connection.name, + integration: connection.integration, + displayName: connection.displayName, + isExisting: true, + }; + event.dataTransfer.setData("application/workflow-node", JSON.stringify(dragData)); + event.dataTransfer.effectAllowed = "move"; + }; + + const handleIntegrationDragStart = (event: DragEvent, integration: IntegrationSelectOption) => { + const dragData = { + nodeType: "connection", + integration: integration.value, + displayName: integration.label, + isExisting: false, + }; + event.dataTransfer.setData("application/workflow-node", JSON.stringify(dragData)); + event.dataTransfer.effectAllowed = "move"; + }; + + return ( +
+ + + {isExpanded ? ( +
+ {projectConnections.length > 0 ? ( +
+ + Project Connections + +
+ {projectConnections.map((conn) => ( + + ))} +
+
+ ) : null} + + + + {showAvailable ? ( +
+ + Available Integrations + +
+ {availableIntegrations.slice(0, 12).map((integration) => ( + + ))} +
+ {availableIntegrations.length > 12 ? ( + + +{availableIntegrations.length - 12} more integrations + + ) : null} +
+ ) : null} +
+ ) : null} +
+ ); +}; diff --git a/src/components/organisms/workflowBuilder/sidebar/index.ts b/src/components/organisms/workflowBuilder/sidebar/index.ts new file mode 100644 index 0000000000..e70fb9e974 --- /dev/null +++ b/src/components/organisms/workflowBuilder/sidebar/index.ts @@ -0,0 +1,5 @@ +export { CodeFilesSection } from "./codeFilesSection"; +export { ConnectionsSection } from "./connectionsSection"; +export { TriggerSection } from "./triggerSection"; +export { VariablesSection } from "./variablesSection"; +export { WorkflowSidebar } from "./workflowSidebar"; diff --git a/src/components/organisms/workflowBuilder/sidebar/triggerSection.tsx b/src/components/organisms/workflowBuilder/sidebar/triggerSection.tsx new file mode 100644 index 0000000000..01305c5052 --- /dev/null +++ b/src/components/organisms/workflowBuilder/sidebar/triggerSection.tsx @@ -0,0 +1,115 @@ +import React, { DragEvent, useState } from "react"; + +import { LuChevronDown, LuClock, LuLink, LuZap } from "react-icons/lu"; + +import { TriggerType } from "@interfaces/components/workflowBuilder.interface"; +import { cn } from "@src/utilities"; + +import { Typography } from "@components/atoms"; + +interface TriggerItem { + type: TriggerType; + label: string; + description: string; + icon: React.ElementType; + color: string; + bgColor: string; +} + +const triggerItems: TriggerItem[] = [ + { + type: "schedule", + label: "Schedule", + description: "Run on a cron schedule", + icon: LuClock, + color: "text-amber-400", + bgColor: "bg-amber-500/20", + }, + { + type: "webhook", + label: "Webhook", + description: "Triggered by HTTP request", + icon: LuLink, + color: "text-purple-400", + bgColor: "bg-purple-500/20", + }, + { + type: "event", + label: "Event", + description: "Listen to connection events", + icon: LuZap, + color: "text-blue-400", + bgColor: "bg-blue-500/20", + }, +]; + +interface DraggableTriggerProps { + trigger: TriggerItem; + onDragStart: (event: DragEvent, trigger: TriggerItem) => void; +} + +const DraggableTrigger = ({ trigger, onDragStart }: DraggableTriggerProps) => { + const Icon = trigger.icon; + + return ( +
onDragStart(event, trigger)} + > +
+ +
+
+ + {trigger.label} + + + {trigger.description} + +
+
+ ); +}; + +export const TriggerSection = () => { + const [isExpanded, setIsExpanded] = useState(true); + + const handleDragStart = (event: DragEvent, trigger: TriggerItem) => { + const dragData = { + nodeType: "trigger", + triggerType: trigger.type, + label: trigger.label, + }; + event.dataTransfer.setData("application/workflow-node", JSON.stringify(dragData)); + event.dataTransfer.effectAllowed = "move"; + }; + + return ( +
+ + + {isExpanded ? ( +
+ {triggerItems.map((trigger) => ( + + ))} +
+ ) : null} +
+ ); +}; diff --git a/src/components/organisms/workflowBuilder/sidebar/variablesSection.tsx b/src/components/organisms/workflowBuilder/sidebar/variablesSection.tsx new file mode 100644 index 0000000000..7c49cf8520 --- /dev/null +++ b/src/components/organisms/workflowBuilder/sidebar/variablesSection.tsx @@ -0,0 +1,268 @@ +import React, { useCallback, useState } from "react"; + +import { LuChevronDown, LuEye, LuEyeOff, LuLock, LuLockOpen, LuPlus, LuSettings, LuTrash2 } from "react-icons/lu"; + +import { ProjectVariable } from "@interfaces/components/workflowBuilder.interface"; +import { cn } from "@src/utilities"; +import { useWorkflowBuilderStore } from "@store/useWorkflowBuilderStore"; + +import { Input, Typography } from "@components/atoms"; + +interface VariableItemProps { + variable: ProjectVariable; + onUpdate: (id: string, updates: Partial) => void; + onDelete: (id: string) => void; +} + +const VariableItem = ({ variable, onUpdate, onDelete }: VariableItemProps) => { + const [isEditing, setIsEditing] = useState(false); + const [showValue, setShowValue] = useState(false); + const [editName, setEditName] = useState(variable.name); + const [editValue, setEditValue] = useState(variable.value); + + const handleSave = useCallback(() => { + onUpdate(variable.id, { name: editName, value: editValue }); + setIsEditing(false); + }, [variable.id, editName, editValue, onUpdate]); + + const handleCancel = useCallback(() => { + setEditName(variable.name); + setEditValue(variable.value); + setIsEditing(false); + }, [variable.name, variable.value]); + + const toggleSecret = useCallback(() => { + onUpdate(variable.id, { isSecret: !variable.isSecret }); + }, [variable.id, variable.isSecret, onUpdate]); + + if (isEditing) { + return ( +
+ setEditName(e.target.value)} + placeholder="Variable name" + value={editName} + /> +
+ setEditValue(e.target.value)} + placeholder="Value" + type={variable.isSecret && !showValue ? "password" : "text"} + value={editValue} + /> + {variable.isSecret ? ( + + ) : null} +
+
+ +
+ + +
+
+
+ ); + } + + return ( +
+
+
+ {variable.isSecret ? : null} + + {variable.name} + +
+ + {variable.isSecret ? "••••••••" : variable.value || "(empty)"} + +
+
+ + +
+
+ ); +}; + +export const VariablesSection = () => { + const [isExpanded, setIsExpanded] = useState(true); + const [isAdding, setIsAdding] = useState(false); + const [newName, setNewName] = useState(""); + const [newValue, setNewValue] = useState(""); + const [newIsSecret, setNewIsSecret] = useState(false); + + const { variables, addVariable, updateVariable, removeVariable } = useWorkflowBuilderStore(); + + const handleAddVariable = useCallback(() => { + if (!newName.trim()) return; + + const newVar: ProjectVariable = { + id: `var-${Date.now()}`, + name: newName.trim().toUpperCase().replace(/\s+/g, "_"), + value: newValue, + isSecret: newIsSecret, + }; + + addVariable(newVar); + setNewName(""); + setNewValue(""); + setNewIsSecret(false); + setIsAdding(false); + }, [newName, newValue, newIsSecret, addVariable]); + + const handleCancelAdd = useCallback(() => { + setNewName(""); + setNewValue(""); + setNewIsSecret(false); + setIsAdding(false); + }, []); + + return ( +
+ + + {isExpanded ? ( +
+ {variables.map((variable) => ( + + ))} + + {isAdding ? ( +
+ setNewName(e.target.value)} + placeholder="VARIABLE_NAME" + value={newName} + /> + setNewValue(e.target.value)} + placeholder="Value" + type={newIsSecret ? "password" : "text"} + value={newValue} + /> +
+ +
+ + +
+
+
+ ) : ( + + )} +
+ ) : null} +
+ ); +}; diff --git a/src/components/organisms/workflowBuilder/sidebar/workflowSidebar.tsx b/src/components/organisms/workflowBuilder/sidebar/workflowSidebar.tsx new file mode 100644 index 0000000000..5457137d6c --- /dev/null +++ b/src/components/organisms/workflowBuilder/sidebar/workflowSidebar.tsx @@ -0,0 +1,46 @@ +import React, { useState } from "react"; + +import { LuSearch } from "react-icons/lu"; + +import { CodeFilesSection } from "./codeFilesSection"; +import { ConnectionsSection } from "./connectionsSection"; +import { TriggerSection } from "./triggerSection"; +import { VariablesSection } from "./variablesSection"; + +import { Input, Typography } from "@components/atoms"; + +export const WorkflowSidebar = () => { + const [searchQuery, setSearchQuery] = useState(""); + + return ( + + ); +}; diff --git a/src/components/organisms/workflowBuilder/workflowBuilder.tsx b/src/components/organisms/workflowBuilder/workflowBuilder.tsx new file mode 100644 index 0000000000..dddf78b619 --- /dev/null +++ b/src/components/organisms/workflowBuilder/workflowBuilder.tsx @@ -0,0 +1,142 @@ +import React, { useEffect, useCallback } from "react"; + +import { ReactFlowProvider } from "@xyflow/react"; +import { useTranslation } from "react-i18next"; +import { useParams } from "react-router-dom"; + +import { ConnectionEditorModal } from "./connectionEditorModal"; +import { DeleteEdgeModal } from "./deleteEdgeModal"; +import { DeleteNodeModal } from "./deleteNodeModal"; +import { ConnectionConfigModal, TriggerConfigModal } from "./modals"; +import { WorkflowSidebar } from "./sidebar"; +import { WorkflowBuilderError } from "./workflowBuilderError"; +import { WorkflowBuilderSkeleton } from "./workflowBuilderSkeleton"; +import { WorkflowBuilderWarnings } from "./workflowBuilderWarnings"; +import { WorkflowCanvas } from "./workflowCanvas"; +import { useWorkflowBuilderStore } from "@store/useWorkflowBuilderStore"; + +import { Button, Typography } from "@components/atoms"; + +interface WorkflowBuilderProps { + projectId?: string; + buildId?: string; +} + +export const WorkflowBuilder = ({ projectId: propProjectId, buildId }: WorkflowBuilderProps) => { + const { projectId: paramProjectId } = useParams<{ projectId: string }>(); + const projectId = propProjectId || paramProjectId; + const { t } = useTranslation("workflowBuilder"); + const { + clearWorkflow, + nodes, + edges, + variables, + getTriggerNodes, + getCodeNodes, + getConnectionNodes, + isLoadingProject, + loadError, + loadProjectWorkflow, + clearLoadError, + warnings, + hasUnsavedChanges, + } = useWorkflowBuilderStore(); + + useEffect(() => { + if (projectId) { + loadProjectWorkflow(projectId, buildId); + } + }, [projectId, buildId, loadProjectWorkflow]); + + const handleRetry = useCallback(() => { + if (projectId) { + loadProjectWorkflow(projectId, buildId); + } + }, [projectId, buildId, loadProjectWorkflow]); + + const triggerCount = getTriggerNodes().length; + const codeCount = getCodeNodes().length; + const connectionCount = getConnectionNodes().length; + + if (isLoadingProject) { + return ; + } + + if (loadError) { + return ; + } + + return ( + +
+
+
+ + {t("title")} + + + {t("description")} + +
+
+
+
+ + + {triggerCount} {triggerCount === 1 ? "trigger" : "triggers"} + +
+
+ + + {codeCount} {codeCount === 1 ? "file" : "files"} + +
+
+ + + {connectionCount} {connectionCount === 1 ? "connection" : "connections"} + +
+ {variables.length > 0 ? ( +
+ + + {variables.length} {variables.length === 1 ? "variable" : "variables"} + +
+ ) : null} +
+ {hasUnsavedChanges ? ( +
+ + {t("unsavedChanges")} +
+ ) : null} +
+ + {t("stats", { nodes: nodes.length, edges: edges.length })} + + {nodes.length > 0 || edges.length > 0 ? ( + + ) : null} +
+
+
+ +
+ + +
+
+
+ + + + + + + ); +}; diff --git a/src/components/organisms/workflowBuilder/workflowBuilderError.tsx b/src/components/organisms/workflowBuilder/workflowBuilderError.tsx new file mode 100644 index 0000000000..0f84a22a61 --- /dev/null +++ b/src/components/organisms/workflowBuilder/workflowBuilderError.tsx @@ -0,0 +1,51 @@ +import React from "react"; + +import { LuTriangleAlert } from "react-icons/lu"; + +import { cn } from "@utilities"; + +import { Button } from "@components/atoms/buttons"; + +interface WorkflowBuilderErrorProps { + error: string; + onRetry?: () => void; + onDismiss?: () => void; + className?: string; +} + +export const WorkflowBuilderError = ({ error, onRetry, onDismiss, className }: WorkflowBuilderErrorProps) => { + return ( +
+
+
+ +
+ +

Failed to Load Workflow

+ +

{error}

+
+ +
+ {onRetry ? ( + + ) : null} + + {onDismiss ? ( + + ) : null} +
+
+ ); +}; diff --git a/src/components/organisms/workflowBuilder/workflowBuilderSkeleton.tsx b/src/components/organisms/workflowBuilder/workflowBuilderSkeleton.tsx new file mode 100644 index 0000000000..3dec4b4c47 --- /dev/null +++ b/src/components/organisms/workflowBuilder/workflowBuilderSkeleton.tsx @@ -0,0 +1,58 @@ +import React from "react"; + +import { cn } from "@utilities"; + +interface WorkflowBuilderSkeletonProps { + className?: string; +} + +const SkeletonNode = ({ className }: { className?: string }) => ( +
+
+
+
+); + +const SkeletonEdge = ({ className }: { className?: string }) => ( +
+); + +export const WorkflowBuilderSkeleton = ({ className }: WorkflowBuilderSkeletonProps) => { + return ( +
+
+
+ + +
+ +
+ + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + +
+ +

Loading workflow...

+
+
+ ); +}; diff --git a/src/components/organisms/workflowBuilder/workflowBuilderWarnings.tsx b/src/components/organisms/workflowBuilder/workflowBuilderWarnings.tsx new file mode 100644 index 0000000000..7ab5a2748b --- /dev/null +++ b/src/components/organisms/workflowBuilder/workflowBuilderWarnings.tsx @@ -0,0 +1,82 @@ +import React, { useState } from "react"; + +import { LuCircleAlert, LuChevronDown, LuChevronUp, LuX } from "react-icons/lu"; + +import { GraphBuildWarning } from "@interfaces/components/workflowBuilder.interface"; +import { cn } from "@utilities"; + +interface WorkflowBuilderWarningsProps { + warnings: GraphBuildWarning[]; + onDismiss?: () => void; + className?: string; +} + +const warningTypeLabels: Record = { + orphan_trigger: "Orphan Trigger", + unused_connection: "Unused Connection", + missing_entry_point: "Missing Entry Point", + circular_dependency: "Circular Dependency", + invalid_connection_ref: "Invalid Connection Reference", +}; + +export const WorkflowBuilderWarnings = ({ warnings, onDismiss, className }: WorkflowBuilderWarningsProps) => { + const [isExpanded, setIsExpanded] = useState(false); + + if (warnings.length === 0) { + return null; + } + + return ( +
+
+ + + {onDismiss ? ( + + ) : null} +
+ + {isExpanded ? ( +
+
    + {warnings.map((warning, index) => ( +
  • + + {warningTypeLabels[warning.type] || warning.type}: + {" "} + {warning.message} +
  • + ))} +
+
+ ) : null} +
+ ); +}; diff --git a/src/components/organisms/workflowBuilder/workflowCanvas.tsx b/src/components/organisms/workflowBuilder/workflowCanvas.tsx new file mode 100644 index 0000000000..bd8684086a --- /dev/null +++ b/src/components/organisms/workflowBuilder/workflowCanvas.tsx @@ -0,0 +1,266 @@ +import React, { useCallback, DragEvent, useMemo } from "react"; + +import { + ReactFlow, + Background, + Controls, + Connection, + useNodesState, + useEdgesState, + addEdge, + NodeTypes, + EdgeTypes, + OnConnect, + OnNodesChange, + OnEdgesChange, + Node, + Edge, + MarkerType, + useReactFlow, +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; + +import { CodeEdge } from "./codeEdge"; +import { DataEdge, ExecutionEdge } from "./edges"; +import { IntegrationNode } from "./integrationNode"; +import { CodeNode, ConnectionNode, TriggerNode } from "./nodes"; +import { + CodeNodeData, + ConnectionNodeData, + DataEdgeData, + ExecutionEdgeData, + LegacyWorkflowEdgeData, + TriggerNodeData, + WorkflowEdge, + WorkflowNode, +} from "@interfaces/components/workflowBuilder.interface"; +import { ModalName } from "@src/enums"; +import { Integrations } from "@src/enums/components"; +import { useModalStore } from "@store/useModalStore"; +import { useWorkflowBuilderStore } from "@store/useWorkflowBuilderStore"; + +const nodeTypes: NodeTypes = { + integration: IntegrationNode, + trigger: TriggerNode, + code: CodeNode, + connection: ConnectionNode, +}; + +const edgeTypes: EdgeTypes = { + code: CodeEdge, + execution: ExecutionEdge, + data: DataEdge, +}; + +export const WorkflowCanvas = () => { + const { nodes: storeNodes, edges: storeEdges, setNodes, setEdges, setSelectedEdgeId } = useWorkflowBuilderStore(); + const { openModal } = useModalStore(); + const { screenToFlowPosition } = useReactFlow(); + + const [, , onNodesChange] = useNodesState(storeNodes as Node[]); + const [, , onEdgesChange] = useEdgesState(storeEdges as Edge[]); + + const handleNodesChange: OnNodesChange = useCallback( + (changes) => { + onNodesChange(changes); + const updatedNodes = [...storeNodes]; + changes.forEach((change) => { + if (change.type === "position" && change.position) { + const nodeIndex = updatedNodes.findIndex((n) => n.id === change.id); + if (nodeIndex !== -1) { + updatedNodes[nodeIndex] = { + ...updatedNodes[nodeIndex], + position: change.position, + }; + } + } + }); + setNodes(updatedNodes); + }, + [storeNodes, onNodesChange, setNodes] + ); + + const handleEdgesChange: OnEdgesChange = useCallback( + (changes) => { + onEdgesChange(changes); + }, + [onEdgesChange] + ); + + const onConnect: OnConnect = useCallback( + (connection: Connection) => { + const sourceNode = storeNodes.find((n) => n.id === connection.source); + const targetNode = storeNodes.find((n) => n.id === connection.target); + + if (!sourceNode || !targetNode) return; + + let edgeType: string = "code"; + let edgeData: ExecutionEdgeData | DataEdgeData | LegacyWorkflowEdgeData; + + if (sourceNode.type === "trigger" && targetNode.type === "code") { + edgeType = "execution"; + const triggerData = sourceNode.data as TriggerNodeData; + edgeData = { + type: "execution", + functionCall: triggerData.call?.split(":")[1] || "main", + isActive: triggerData.status === "configured", + } as ExecutionEdgeData; + } else if ( + (sourceNode.type === "code" && targetNode.type === "connection") || + (sourceNode.type === "connection" && targetNode.type === "code") + ) { + edgeType = "data"; + edgeData = { + type: "data", + operations: [], + } as DataEdgeData; + } else { + edgeData = { + code: "", + eventType: "", + variables: [], + status: "draft", + } as LegacyWorkflowEdgeData; + } + + const newEdge = { + id: `edge-${connection.source}-${connection.target}-${Date.now()}`, + source: connection.source!, + target: connection.target!, + sourceHandle: connection.sourceHandle, + targetHandle: connection.targetHandle, + type: edgeType, + animated: edgeType === "execution", + markerEnd: { + type: MarkerType.ArrowClosed, + color: edgeType === "execution" ? "#f59e0b" : "#22c55e", + }, + data: edgeData, + } as WorkflowEdge; + + const updatedEdges = addEdge(newEdge as Edge, storeEdges as Edge[]) as WorkflowEdge[]; + setEdges(updatedEdges); + + if (edgeType === "code") { + setSelectedEdgeId(newEdge.id); + openModal(ModalName.connectionCodeEditor, { edgeId: newEdge.id }); + } + }, + [storeNodes, storeEdges, setEdges, setSelectedEdgeId, openModal] + ); + + const onDragOver = useCallback((event: DragEvent) => { + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + }, []); + + const onDrop = useCallback( + (event: DragEvent) => { + event.preventDefault(); + + const workflowData = event.dataTransfer.getData("application/workflow-node"); + const reactFlowData = event.dataTransfer.getData("application/reactflow"); + + const position = screenToFlowPosition({ + x: event.clientX, + y: event.clientY, + }); + + if (workflowData) { + const data = JSON.parse(workflowData); + + if (data.nodeType === "trigger") { + const newNode: WorkflowNode = { + id: `trigger-${data.triggerType}-${Date.now()}`, + type: "trigger", + position, + data: { + type: data.triggerType, + name: `${data.triggerType}_trigger`, + call: "", + isDurable: true, + status: "draft", + } as TriggerNodeData, + }; + setNodes([...storeNodes, newNode]); + openModal(ModalName.triggerConfig, { nodeId: newNode.id }); + } else if (data.nodeType === "code") { + const newNode: WorkflowNode = { + id: `code-${Date.now()}`, + type: "code", + position, + data: { + fileName: data.fileName, + filePath: data.filePath, + language: data.language, + entryPoints: data.entryPoints || [], + usedConnections: [], + status: "draft", + } as CodeNodeData, + }; + setNodes([...storeNodes, newNode]); + } else if (data.nodeType === "connection") { + const connectionName = data.isExisting + ? data.connectionName + : `${data.integration}_conn_${Date.now().toString(36)}`; + + const newNode: WorkflowNode = { + id: `connection-${connectionName}-${Date.now()}`, + type: "connection", + position, + data: { + connectionName, + integration: data.integration as Integrations, + displayName: data.displayName, + status: data.isExisting ? "connected" : "disconnected", + usedByFunctions: [], + } as ConnectionNodeData, + }; + setNodes([...storeNodes, newNode]); + + if (!data.isExisting) { + openModal(ModalName.connectionConfig, { nodeId: newNode.id, connectionName }); + } + } + } else if (reactFlowData) { + const { value: integrationValue, label } = JSON.parse(reactFlowData); + + const newNode: WorkflowNode = { + id: `node-${integrationValue}-${Date.now()}`, + type: "integration", + position, + data: { + integration: integrationValue as Integrations, + label, + }, + }; + + setNodes([...storeNodes, newNode]); + } + }, + [storeNodes, setNodes, screenToFlowPosition, openModal] + ); + + const memoizedNodes = useMemo(() => storeNodes as Node[], [storeNodes]); + const memoizedEdges = useMemo(() => storeEdges as Edge[], [storeEdges]); + + return ( +
+ + + + +
+ ); +}; diff --git a/src/constants/namespaces.logger.constants.ts b/src/constants/namespaces.logger.constants.ts index 00bceb7f11..fdf08f97d2 100644 --- a/src/constants/namespaces.logger.constants.ts +++ b/src/constants/namespaces.logger.constants.ts @@ -82,4 +82,5 @@ export const namespaces = { }, feedbackForm: "User Feedback Form", datadog: "Datadog", + workflowBuilderService: "Workflow Builder Service", }; diff --git a/src/enums/components/modal.enum.ts b/src/enums/components/modal.enum.ts index d76ac53aa4..12f028f5c9 100644 --- a/src/enums/components/modal.enum.ts +++ b/src/enums/components/modal.enum.ts @@ -42,4 +42,10 @@ export enum ModalName { diagramViewer = "diagramViewer", codeFixDiffEditor = "codeFixDiffEditor", documentationModal = "documentationModal", + connectionCodeEditor = "connectionCodeEditor", + deleteWorkflowEdge = "deleteWorkflowEdge", + deleteWorkflowNode = "deleteWorkflowNode", + triggerConfig = "triggerConfig", + connectionConfig = "connectionConfig", + codeEditor = "codeEditor", } diff --git a/src/interfaces/components/index.ts b/src/interfaces/components/index.ts index 8de6619516..ebf5baffbf 100644 --- a/src/interfaces/components/index.ts +++ b/src/interfaces/components/index.ts @@ -164,3 +164,11 @@ export type { TotalCountersData, } from "./dashboardStats.interface"; export type { ProjectsTableMeta } from "./projectsTable.interface"; +export type { + IntegrationNodeData, + WorkflowNode, + WorkflowEdge, + WorkflowBuilderState, + LegacyWorkflowEdgeData, + WorkflowEdgeVariable, +} from "./workflowBuilder.interface"; diff --git a/src/interfaces/components/modal.interface.ts b/src/interfaces/components/modal.interface.ts index 2c966dc24c..e3b9330941 100644 --- a/src/interfaces/components/modal.interface.ts +++ b/src/interfaces/components/modal.interface.ts @@ -1,6 +1,7 @@ import { TemplateMetadata } from "@interfaces/store"; import { SelectOption } from "@src/interfaces/components"; import { EnrichedEvent, EnrichedOrganization, Variable } from "@src/types/models"; +import { ColorSchemes } from "@type"; export interface ModalProps { "data-testid"?: string; @@ -15,6 +16,7 @@ export interface ModalProps { forceOpen?: boolean; onCloseCallbackOverride?: () => void; clickOverlayToClose?: boolean; + variant?: ColorSchemes; } export interface DeleteModalProps { onDelete: () => void; diff --git a/src/interfaces/components/workflowBuilder.interface.ts b/src/interfaces/components/workflowBuilder.interface.ts new file mode 100644 index 0000000000..e125206005 --- /dev/null +++ b/src/interfaces/components/workflowBuilder.interface.ts @@ -0,0 +1,162 @@ +import { Node, Edge } from "@xyflow/react"; + +import { Integrations } from "@src/enums/components"; + +export type TriggerType = "schedule" | "webhook" | "event"; +export type NodeStatus = "draft" | "configured" | "active" | "error"; +export type ConnectionStatus = "disconnected" | "connected" | "active" | "error"; + +export interface ProjectVariable { + id: string; + name: string; + value: string; + isSecret: boolean; +} + +export interface EntryPoint { + name: string; + lineNumber: number; + isActive: boolean; +} + +export interface TriggerNodeData extends Record { + type: TriggerType; + name: string; + schedule?: string; + webhookPath?: string; + httpMethod?: "GET" | "POST" | "PUT" | "DELETE"; + connectionRef?: string; + eventType?: string; + eventFilter?: string; + call: string; + isDurable: boolean; + status: NodeStatus; +} + +export interface CodeNodeData extends Record { + fileName: string; + filePath: string; + language: "python" | "starlark"; + entryPoints: EntryPoint[]; + usedConnections: string[]; + status: NodeStatus; +} + +export interface ConnectionNodeData extends Record { + connectionName: string; + integration: Integrations; + displayName: string; + status: ConnectionStatus; + usedByFunctions: string[]; +} + +export interface IntegrationNodeData extends Record { + integration: Integrations; + label: string; + isTrigger?: boolean; +} + +export type TriggerNode = Node; +export type CodeNode = Node; +export type ConnectionNode = Node; +export type IntegrationNode = Node; + +export type WorkflowNode = TriggerNode | CodeNode | ConnectionNode | IntegrationNode; + +export interface ExecutionEdgeData extends Record { + type: "execution"; + functionCall: string; + isActive: boolean; +} + +export interface DataEdgeData extends Record { + type: "data"; + operations: { + functionName: string; + lineNumber?: number; + operationType: "read" | "write"; + }[]; +} + +export interface EventEdgeData extends Record { + type: "event"; + eventType: string; + filter?: string; +} + +export interface WorkflowEdgeVariable { + key: string; + value: string; + isSecret?: boolean; +} + +export type EdgeStatus = "draft" | "configured" | "active" | "error"; + +export interface LegacyWorkflowEdgeData extends Record { + code: string; + eventType: string; + variables: WorkflowEdgeVariable[]; + status: EdgeStatus; +} + +export type ExecutionEdge = Edge; +export type DataEdge = Edge; +export type EventEdge = Edge; +export type LegacyWorkflowEdge = Edge; + +export type WorkflowEdge = ExecutionEdge | DataEdge | EventEdge | LegacyWorkflowEdge; + +export interface GraphBuildWarning { + type: string; + message: string; + affectedIds: string[]; +} + +export interface WorkflowBuilderState { + nodes: WorkflowNode[]; + edges: WorkflowEdge[]; + variables: ProjectVariable[]; + selectedNodeId: string | null; + selectedEdgeId: string | null; + triggerNodeId: string | null; + + projectId: string | null; + buildId: string | null; + isLoadingProject: boolean; + loadError: string | null; + hasUnsavedChanges: boolean; + warnings: GraphBuildWarning[]; + + addNode: (node: WorkflowNode) => void; + removeNode: (nodeId: string) => void; + updateNode: (nodeId: string, data: Partial) => void; + updateNodePosition: (nodeId: string, position: { x: number; y: number }) => void; + setNodes: (nodes: WorkflowNode[]) => void; + setSelectedNodeId: (nodeId: string | null) => void; + setTriggerNodeId: (nodeId: string | null) => void; + + addEdge: (edge: WorkflowEdge) => void; + removeEdge: (edgeId: string) => void; + updateEdge: (edgeId: string, data: Partial) => void; + updateEdgeCode: (edgeId: string, code: string) => void; + updateEdgeEventType: (edgeId: string, eventType: string) => void; + updateEdgeVariables: (edgeId: string, variables: WorkflowEdgeVariable[]) => void; + setEdges: (edges: WorkflowEdge[]) => void; + setSelectedEdgeId: (edgeId: string | null) => void; + + addVariable: (variable: ProjectVariable) => void; + updateVariable: (id: string, updates: Partial) => void; + removeVariable: (id: string) => void; + setVariables: (variables: ProjectVariable[]) => void; + + clearWorkflow: () => void; + + getTriggerNodes: () => TriggerNode[]; + getCodeNodes: () => CodeNode[]; + getConnectionNodes: () => ConnectionNode[]; + + loadProjectWorkflow: (projectId: string, buildId?: string) => Promise; + resetToProjectState: () => void; + setHasUnsavedChanges: (hasChanges: boolean) => void; + clearLoadError: () => void; +} diff --git a/src/locales/en/index.ts b/src/locales/en/index.ts index 5cb7a42573..c98f445a33 100644 --- a/src/locales/en/index.ts +++ b/src/locales/en/index.ts @@ -33,6 +33,7 @@ import toasts from "@locales/en/toasts/translation.json"; import tour from "@locales/en/tour/translation.json"; import utilities from "@locales/en/utilities/translation.json"; import validations from "@locales/en/validations/translation.json"; +import workflowBuilder from "@locales/en/workflowBuilder/translation.json"; export default { authentication, @@ -72,4 +73,5 @@ export default { templates, shared, billing, + workflowBuilder, }; diff --git a/src/locales/en/tabs/translation.json b/src/locales/en/tabs/translation.json index 92fe56c802..079f5a0797 100644 --- a/src/locales/en/tabs/translation.json +++ b/src/locales/en/tabs/translation.json @@ -288,4 +288,4 @@ "variableRemovedSuccessfully": "{{variableName}} removed successfully", "variableRemovedSuccessfullyExtended": "{{variableName}} removed successfully, variable ID: {{variableId}}" } -} \ No newline at end of file +} diff --git a/src/locales/en/workflowBuilder/translation.json b/src/locales/en/workflowBuilder/translation.json new file mode 100644 index 0000000000..c8372f1738 --- /dev/null +++ b/src/locales/en/workflowBuilder/translation.json @@ -0,0 +1,164 @@ +{ + "title": "Workflow Builder", + "description": "Build your automation workflow by connecting triggers, code, and integrations", + "clearCanvas": "Clear Canvas", + "stats": "{{nodes}} nodes, {{edges}} edges", + "unsavedChanges": "Unsaved changes", + "sidebar": { + "title": "Workflow Components", + "description": "Drag components to the canvas", + "search": "Search components...", + "triggers": { + "title": "Triggers", + "description": "Events that start your workflow", + "schedule": "Schedule", + "scheduleDesc": "Run on a time-based schedule", + "webhook": "Webhook", + "webhookDesc": "Triggered by HTTP requests", + "event": "Event", + "eventDesc": "Respond to external events" + }, + "codeFiles": { + "title": "Code Files", + "description": "Your project's code files", + "empty": "No code files in project", + "entryPoints": "Entry points" + }, + "connections": { + "title": "Connections", + "description": "Available integrations", + "projectConnections": "Project Connections", + "availableIntegrations": "Available Integrations", + "empty": "No connections configured" + }, + "variables": { + "title": "Variables", + "description": "Global project variables", + "empty": "No variables defined", + "addVariable": "Add Variable", + "namePlaceholder": "Variable name", + "valuePlaceholder": "Value", + "secret": "Secret", + "notSecret": "Not secret" + } + }, + "nodes": { + "trigger": { + "schedule": "Schedule Trigger", + "webhook": "Webhook Trigger", + "event": "Event Trigger", + "calls": "Calls", + "durable": "Durable", + "notDurable": "Not durable", + "draft": "Draft", + "configured": "Configured", + "active": "Active", + "error": "Error" + }, + "code": { + "entryPoints": "Entry Points", + "connections": "Connections", + "noEntryPoints": "No entry points", + "noConnections": "No connections used" + }, + "connection": { + "disconnected": "Disconnected", + "connected": "Connected", + "active": "Active", + "error": "Error", + "usedBy": "Used by" + } + }, + "edges": { + "execution": { + "calls": "calls" + }, + "data": { + "operations": "operations", + "read": "Read", + "write": "Write" + } + }, + "modal": { + "title": "Connection Code Editor", + "description": "Define the logic that executes when data flows between these integrations", + "hint": "Use Python to transform data between integrations", + "save": "Save", + "cancel": "Cancel", + "eventType": "Event Type", + "selectEventType": "Select an event type", + "noEventTypes": "No event types available for this integration" + }, + "triggerConfig": { + "title": "Configure Trigger", + "name": "Trigger Name", + "namePlaceholder": "my_trigger", + "type": "Trigger Type", + "schedule": { + "label": "Schedule", + "cronExpression": "Cron Expression", + "cronPlaceholder": "0 * * * *", + "presets": { + "everyMinute": "Every minute", + "everyHour": "Every hour", + "dailyMidnight": "Daily at midnight", + "weekly": "Weekly (Sunday)", + "biWeekly": "Bi-weekly", + "monthly": "Monthly" + } + }, + "webhook": { + "label": "Webhook", + "httpMethod": "HTTP Method", + "path": "Webhook Path", + "pathPlaceholder": "/webhook/my-endpoint" + }, + "event": { + "label": "Event", + "eventType": "Event Type", + "eventTypePlaceholder": "slack_message_received" + }, + "entryPoint": "Entry Point (function to call)", + "selectEntryPoint": "Select an entry point...", + "entryPointPlaceholder": "program.py:main", + "durable": "Durable (survives restarts)", + "save": "Save Trigger", + "cancel": "Cancel" + }, + "connectionConfig": { + "title": "Configure Connection", + "connectionName": "Connection Name", + "connectionNamePlaceholder": "my_connection", + "connectionNameHint": "Use this name to reference the connection in your code", + "status": "Status", + "disconnected": "Disconnected", + "connected": "Connected", + "active": "Active", + "error": "Error", + "testConnection": "Test Connection", + "testing": "Testing...", + "disconnect": "Disconnect", + "authRequired": "Authentication Required", + "authRequiredDesc": "This connection needs to be authenticated before it can be used. Click the button below to set up authentication.", + "setupAuth": "Set Up Authentication", + "usedInCode": "Used in Code", + "notUsed": "This connection is not used in any code files yet", + "save": "Save Connection", + "cancel": "Cancel" + }, + "deleteModal": { + "title": "Delete Connection", + "content": "Are you sure you want to delete this connection?", + "warning": "This action cannot be undone.", + "cancel": "Cancel", + "delete": "Delete" + }, + "deleteNodeModal": { + "title": "Delete Node", + "content": "Are you sure you want to delete \"{{name}}\"?", + "connectionWarning": "This node has {{count}} connection(s) that will also be deleted.", + "warning": "This action cannot be undone.", + "cancel": "Cancel", + "delete": "Delete" + } +} diff --git a/src/routes.tsx b/src/routes.tsx index 6e871cb96d..e075f0ac00 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -13,6 +13,7 @@ import { ProtectedRoute, SessionsTable, OrgConnectionsTable, + WorkflowBuilder, } from "@components/organisms"; import { AddConnection, EditConnection } from "@components/organisms/configuration/connections"; import { TemplatesCatalog } from "@components/organisms/dashboard/templates"; @@ -155,6 +156,19 @@ export const mainRoutes = [ }, ], }, + { + path: "projects/:projectId/canvas", + element: , + children: [ + { + element: , + children: [ + { index: true, element: }, + { path: "settings/*", element: }, + ], + }, + ], + }, { path: "settings", element: ( diff --git a/src/services/index.ts b/src/services/index.ts index a923f76ed8..d70cff8946 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -15,6 +15,12 @@ export { VariablesService } from "@services/variables.service"; export { OrganizationsService } from "@services/organizations.service"; export { UsersService } from "@services/users.service"; export { VersionService } from "@services/version.service"; +export { WorkflowBuilderService } from "@services/workflowBuilder.service"; +export type { + ProjectWorkflowData, + ExistingCodeFile, + LoadProjectWorkflowDataOptions, +} from "@services/workflowBuilder.service"; export { IndexedDBService, diff --git a/src/services/workflowBuilder.service.ts b/src/services/workflowBuilder.service.ts new file mode 100644 index 0000000000..10eb135662 --- /dev/null +++ b/src/services/workflowBuilder.service.ts @@ -0,0 +1,220 @@ +import { t } from "i18next"; + +import { namespaces } from "@constants"; +import { ConnectionService, LoggerService, TriggersService, VariablesService, BuildsService } from "@services"; +import { ServiceResponse } from "@type"; +import { Trigger, Connection, Variable } from "@type/models"; + +export interface ExistingCodeFile { + path: string; + name: string; + content?: string; + language: "python" | "starlark"; + exports?: string[]; +} + +export interface ProjectWorkflowData { + triggers: Trigger[]; + files: ExistingCodeFile[]; + connections: Connection[]; + variables: Variable[]; +} + +export interface LoadProjectWorkflowDataOptions { + includeTriggers?: boolean; + includeConnections?: boolean; + includeVariables?: boolean; + includeFiles?: boolean; +} + +const defaultLoadOptions: LoadProjectWorkflowDataOptions = { + includeTriggers: true, + includeConnections: true, + includeVariables: true, + includeFiles: true, +}; + +export class WorkflowBuilderService { + static async loadProjectWorkflowData( + projectId: string, + buildId?: string, + options: LoadProjectWorkflowDataOptions = defaultLoadOptions + ): Promise> { + try { + const promises: Promise[] = []; + const promiseKeys: (keyof ProjectWorkflowData)[] = []; + + if (options.includeTriggers) { + promises.push(TriggersService.list(projectId)); + promiseKeys.push("triggers"); + } + + if (options.includeConnections) { + promises.push(ConnectionService.list(projectId)); + promiseKeys.push("connections"); + } + + if (options.includeVariables) { + promises.push(VariablesService.list(projectId)); + promiseKeys.push("variables"); + } + + if (options.includeFiles && buildId) { + promises.push(WorkflowBuilderService.loadBuildFiles(buildId)); + promiseKeys.push("files"); + } + + const results = await Promise.allSettled(promises); + + const data: ProjectWorkflowData = { + triggers: [], + files: [], + connections: [], + variables: [], + }; + + const errors: string[] = []; + + results.forEach((result, index) => { + const key = promiseKeys[index]; + + if (result.status === "fulfilled") { + const response = result.value as { data?: unknown; error?: unknown }; + if (response.data) { + (data[key] as unknown) = response.data; + } else if (response.error) { + errors.push(`Failed to load ${key}: ${response.error}`); + } + } else { + errors.push(`Failed to load ${key}: ${result.reason}`); + } + }); + + if (errors.length > 0 && Object.values(data).every((array) => array.length === 0)) { + const errorMessage = t("workflowDataLoadFailed", { + ns: "services", + errors: errors.join("; "), + }); + LoggerService.error(namespaces.workflowBuilderService, errorMessage); + + return { data: undefined, error: new Error(errorMessage) }; + } + + if (errors.length > 0) { + LoggerService.warn(namespaces.workflowBuilderService, `Partial load: ${errors.join("; ")}`); + } + + return { data, error: undefined }; + } catch (error) { + const errorMessage = t("workflowDataLoadError", { + ns: "services", + error: (error as Error).message, + projectId, + }); + LoggerService.error(namespaces.workflowBuilderService, errorMessage); + + return { data: undefined, error }; + } + } + + static async loadBuildFiles(buildId: string): Promise<{ data?: ExistingCodeFile[]; error?: unknown }> { + try { + const { data: buildDescription, error } = await BuildsService.getBuildDescription(buildId); + + if (error || !buildDescription) { + return { data: undefined, error }; + } + + const buildInfo = JSON.parse(buildDescription); + const files = WorkflowBuilderService.extractFilesFromBuildInfo(buildInfo); + + return { data: files, error: undefined }; + } catch (error) { + LoggerService.error( + namespaces.workflowBuilderService, + t("buildFilesLoadFailed", { ns: "services", buildId, error: (error as Error).message }) + ); + + return { data: undefined, error }; + } + } + + static extractFilesFromBuildInfo(buildInfo: { runtimes?: Record }): ExistingCodeFile[] { + const files: ExistingCodeFile[] = []; + + if (!buildInfo.runtimes) { + return files; + } + + Object.entries(buildInfo.runtimes).forEach(([, runtime]) => { + const runtimeData = runtime as { + artifact?: { + compiled_data?: Record< + string, + { + exports?: string[]; + path?: string; + } + >; + }; + }; + + if (runtimeData.artifact?.compiled_data) { + Object.entries(runtimeData.artifact.compiled_data).forEach(([filePath, fileData]) => { + const fileName = filePath.split("/").pop() || filePath; + const extension = fileName.split(".").pop()?.toLowerCase(); + const language: "python" | "starlark" = extension === "star" ? "starlark" : "python"; + + files.push({ + path: fileData.path || filePath, + name: fileName, + language, + exports: fileData.exports || [], + }); + }); + } + }); + + return files; + } + + static async loadTriggersOnly(projectId: string): Promise> { + return TriggersService.list(projectId); + } + + static async loadConnectionsOnly(projectId: string): Promise> { + return ConnectionService.list(projectId); + } + + static async loadVariablesOnly(projectId: string): Promise> { + return VariablesService.list(projectId); + } + + static async refreshProjectData( + projectId: string, + buildId?: string, + dataTypes: (keyof ProjectWorkflowData)[] = ["triggers", "connections", "variables", "files"] + ): Promise>> { + const options: LoadProjectWorkflowDataOptions = { + includeTriggers: dataTypes.includes("triggers"), + includeConnections: dataTypes.includes("connections"), + includeVariables: dataTypes.includes("variables"), + includeFiles: dataTypes.includes("files"), + }; + + const result = await WorkflowBuilderService.loadProjectWorkflowData(projectId, buildId, options); + + if (result.data) { + const filteredData: Partial = {}; + dataTypes.forEach((key) => { + if (result.data && result.data[key]) { + (filteredData[key] as unknown) = result.data[key]; + } + }); + + return { data: filteredData, error: undefined }; + } + + return { data: undefined, error: result.error }; + } +} diff --git a/src/store/index.ts b/src/store/index.ts index 4a00ffbaed..370d7b73c7 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -18,3 +18,4 @@ export { useSharedBetweenProjectsStore } from "@store/useSharedBetweenProjectsSt export { useTablePreferencesStore } from "@store/useTablePreferencesStore"; export { useToastStore } from "@store/useToastStore"; export { useTourStore } from "@store/useTourStore"; +export { useWorkflowBuilderStore } from "@store/useWorkflowBuilderStore"; diff --git a/src/store/useWorkflowBuilderStore.ts b/src/store/useWorkflowBuilderStore.ts new file mode 100644 index 0000000000..3d7f49cf03 --- /dev/null +++ b/src/store/useWorkflowBuilderStore.ts @@ -0,0 +1,235 @@ +import { StateCreator } from "zustand"; +import { persist } from "zustand/middleware"; +import { shallow } from "zustand/shallow"; +import { createWithEqualityFn as create } from "zustand/traditional"; + +import { + CodeNode, + ConnectionNode, + GraphBuildWarning, + LegacyWorkflowEdgeData, + ProjectVariable, + TriggerNode, + WorkflowBuilderState, + WorkflowEdge, + WorkflowEdgeVariable, + WorkflowNode, +} from "@interfaces/components/workflowBuilder.interface"; +import { WorkflowBuilderService } from "@services/workflowBuilder.service"; +import { applyAutoLayout, buildWorkflowGraph } from "@utilities/index"; + +interface OriginalProjectState { + nodes: WorkflowNode[]; + edges: WorkflowEdge[]; + variables: ProjectVariable[]; +} + +let originalProjectState: OriginalProjectState | null = null; + +const store: StateCreator = (set, get) => ({ + nodes: [], + edges: [], + variables: [], + selectedNodeId: null, + selectedEdgeId: null, + triggerNodeId: null, + + projectId: null, + buildId: null, + isLoadingProject: false, + loadError: null, + hasUnsavedChanges: false, + warnings: [] as GraphBuildWarning[], + + addNode: (node: WorkflowNode) => + set((state) => ({ + nodes: [...state.nodes, node], + })), + + removeNode: (nodeId: string) => + set((state) => ({ + nodes: state.nodes.filter((node) => node.id !== nodeId), + edges: state.edges.filter((edge) => edge.source !== nodeId && edge.target !== nodeId), + selectedNodeId: state.selectedNodeId === nodeId ? null : state.selectedNodeId, + })), + + updateNode: (nodeId: string, data: Partial) => + set((state) => ({ + nodes: state.nodes.map((node) => + node.id === nodeId ? ({ ...node, data: { ...node.data, ...data } } as WorkflowNode) : node + ), + })), + + updateNodePosition: (nodeId: string, position: { x: number; y: number }) => + set((state) => ({ + nodes: state.nodes.map((node) => (node.id === nodeId ? { ...node, position } : node)), + })), + + setNodes: (nodes: WorkflowNode[]) => set({ nodes }), + + setSelectedNodeId: (nodeId: string | null) => set({ selectedNodeId: nodeId }), + + setTriggerNodeId: (nodeId: string | null) => set({ triggerNodeId: nodeId }), + + addEdge: (edge: WorkflowEdge) => + set((state) => ({ + edges: [...state.edges, edge], + })), + + removeEdge: (edgeId: string) => + set((state) => ({ + edges: state.edges.filter((edge) => edge.id !== edgeId), + selectedEdgeId: state.selectedEdgeId === edgeId ? null : state.selectedEdgeId, + })), + + updateEdge: (edgeId: string, data: Partial) => + set((state) => ({ + edges: state.edges.map((edge) => + edge.id === edgeId ? ({ ...edge, data: { ...edge.data, ...data } } as WorkflowEdge) : edge + ), + })), + + updateEdgeCode: (edgeId: string, code: string) => + set((state) => ({ + edges: state.edges.map((edge) => + edge.id === edgeId ? { ...edge, data: { ...(edge.data as LegacyWorkflowEdgeData), code } } : edge + ), + })), + + updateEdgeEventType: (edgeId: string, eventType: string) => + set((state) => ({ + edges: state.edges.map((edge) => + edge.id === edgeId ? { ...edge, data: { ...(edge.data as LegacyWorkflowEdgeData), eventType } } : edge + ), + })), + + updateEdgeVariables: (edgeId: string, variables: WorkflowEdgeVariable[]) => + set((state) => ({ + edges: state.edges.map((edge) => + edge.id === edgeId ? { ...edge, data: { ...(edge.data as LegacyWorkflowEdgeData), variables } } : edge + ), + })), + + setEdges: (edges: WorkflowEdge[]) => set({ edges }), + + setSelectedEdgeId: (edgeId: string | null) => set({ selectedEdgeId: edgeId }), + + addVariable: (variable: ProjectVariable) => + set((state) => ({ + variables: [...state.variables, variable], + })), + + updateVariable: (id: string, updates: Partial) => + set((state) => ({ + variables: state.variables.map((v) => (v.id === id ? { ...v, ...updates } : v)), + })), + + removeVariable: (id: string) => + set((state) => ({ + variables: state.variables.filter((v) => v.id !== id), + })), + + setVariables: (variables: ProjectVariable[]) => set({ variables }), + + clearWorkflow: () => { + originalProjectState = null; + set({ + nodes: [], + edges: [], + variables: [], + selectedNodeId: null, + selectedEdgeId: null, + triggerNodeId: null, + projectId: null, + buildId: null, + isLoadingProject: false, + loadError: null, + hasUnsavedChanges: false, + warnings: [], + }); + }, + + getTriggerNodes: () => get().nodes.filter((node): node is TriggerNode => node.type === "trigger"), + + getCodeNodes: () => get().nodes.filter((node): node is CodeNode => node.type === "code"), + + getConnectionNodes: () => get().nodes.filter((node): node is ConnectionNode => node.type === "connection"), + + loadProjectWorkflow: async (projectId: string, buildId?: string) => { + set({ + isLoadingProject: true, + loadError: null, + projectId, + buildId: buildId || null, + }); + + const result = await WorkflowBuilderService.loadProjectWorkflowData(projectId, buildId); + + if (!result.data) { + set({ + isLoadingProject: false, + loadError: typeof result.error === "string" ? result.error : "Failed to load project workflow data", + }); + + return; + } + + const graphResult = buildWorkflowGraph(result.data); + const layoutedNodes = applyAutoLayout(graphResult.nodes, graphResult.edges); + + originalProjectState = { + nodes: layoutedNodes, + edges: graphResult.edges, + variables: graphResult.variables, + }; + + set({ + nodes: layoutedNodes, + edges: graphResult.edges, + variables: graphResult.variables, + warnings: graphResult.warnings, + isLoadingProject: false, + loadError: null, + hasUnsavedChanges: false, + selectedNodeId: null, + selectedEdgeId: null, + }); + + const triggerNodes = layoutedNodes.filter((n) => n.type === "trigger"); + if (triggerNodes.length > 0) { + set({ triggerNodeId: triggerNodes[0].id }); + } + }, + + resetToProjectState: () => { + if (originalProjectState) { + set({ + nodes: originalProjectState.nodes, + edges: originalProjectState.edges, + variables: originalProjectState.variables, + hasUnsavedChanges: false, + selectedNodeId: null, + selectedEdgeId: null, + }); + } + }, + + setHasUnsavedChanges: (hasChanges: boolean) => set({ hasUnsavedChanges: hasChanges }), + + clearLoadError: () => set({ loadError: null }), +}); + +export const useWorkflowBuilderStore = create( + persist(store, { + name: "workflow-builder-storage", + partialize: (state) => ({ + nodes: state.nodes, + edges: state.edges, + variables: state.variables, + projectId: state.projectId, + buildId: state.buildId, + warnings: state.warnings, + }), + }), + shallow +); diff --git a/src/utilities/codeParser.ts b/src/utilities/codeParser.ts new file mode 100644 index 0000000000..2cdec4d37a --- /dev/null +++ b/src/utilities/codeParser.ts @@ -0,0 +1,199 @@ +export interface ParsedEntryPoint { + name: string; + lineNumber: number; + parameters: string[]; + isAsync: boolean; + decorators: string[]; + isActive: boolean; +} + +export interface ParseResult { + entryPoints: ParsedEntryPoint[]; + imports: string[]; + connectionReferences: string[]; +} + +const PYTHON_FUNCTION_PATTERN = /^(\s*)(?:(async)\s+)?def\s+(\w+)\s*\((.*?)\)\s*(?:->.*?)?:/gm; +const IMPORT_PATTERN = /^(?:from\s+[\w.]+\s+)?import\s+(.+)$/gm; +const CONNECTION_GET_PATTERN = /(?:ak\.get_connection|autokitteh\.\w+\.connection)\s*\(\s*["']([^"']+)["']\s*\)/g; + +export function parseEntryPoints(content: string, _language: "python" | "starlark" = "python"): ParsedEntryPoint[] { + void _language; + const entryPoints: ParsedEntryPoint[] = []; + const lines = content.split("\n"); + const decoratorsByLine: Map = new Map(); + + lines.forEach((line, index) => { + const decoratorMatch = line.match(/^\s*@(\w+(?:\.\w+)*(?:\([^)]*\))?)/); + if (decoratorMatch) { + const lineNum = index + 1; + const existing = decoratorsByLine.get(lineNum) || []; + existing.push(decoratorMatch[1]); + decoratorsByLine.set(lineNum, existing); + } + }); + + let match; + PYTHON_FUNCTION_PATTERN.lastIndex = 0; + + while ((match = PYTHON_FUNCTION_PATTERN.exec(content)) !== null) { + const [, indent, asyncKeyword, funcName, params] = match; + + if (indent && indent.length > 0) { + continue; + } + + const lineNumber = content.substring(0, match.index).split("\n").length; + + const decorators: string[] = []; + for (let i = lineNumber - 1; i >= 1; i--) { + const lineDecorators = decoratorsByLine.get(i); + if (lineDecorators) { + decorators.unshift(...lineDecorators); + } else { + const prevLine = lines[i - 1]?.trim(); + if (prevLine && !prevLine.startsWith("@") && prevLine !== "") { + break; + } + } + } + + const parameters = parseParameters(params); + + const isActive = + decorators.some( + (d) => + d.startsWith("autokitteh") || + d.startsWith("ak.") || + d === "handler" || + d.includes("handler") || + d.includes("entry") || + d.includes("subscribe") + ) || funcName.startsWith("on_"); + + entryPoints.push({ + name: funcName, + lineNumber, + parameters, + isAsync: !!asyncKeyword, + decorators, + isActive, + }); + } + + return entryPoints; +} + +function parseParameters(paramsString: string): string[] { + if (!paramsString.trim()) { + return []; + } + + const params: string[] = []; + let current = ""; + let depth = 0; + + for (const char of paramsString) { + if (char === "(" || char === "[" || char === "{") { + depth++; + current += char; + } else if (char === ")" || char === "]" || char === "}") { + depth--; + current += char; + } else if (char === "," && depth === 0) { + const param = extractParamName(current.trim()); + if (param && param !== "self" && param !== "cls") { + params.push(param); + } + current = ""; + } else { + current += char; + } + } + + if (current.trim()) { + const param = extractParamName(current.trim()); + if (param && param !== "self" && param !== "cls") { + params.push(param); + } + } + + return params; +} + +function extractParamName(param: string): string { + const colonIndex = param.indexOf(":"); + const equalsIndex = param.indexOf("="); + + let name = param; + if (colonIndex !== -1) { + name = param.substring(0, colonIndex); + } else if (equalsIndex !== -1) { + name = param.substring(0, equalsIndex); + } + + return name.trim().replace(/^\*+/, ""); +} + +export function parseImports(content: string): string[] { + const imports: string[] = []; + let match; + + IMPORT_PATTERN.lastIndex = 0; + + while ((match = IMPORT_PATTERN.exec(content)) !== null) { + const importPart = match[1]; + const items = importPart.split(",").map((item) => item.trim().split(" as ")[0].trim()); + imports.push(...items); + } + + return imports; +} + +export function parseConnectionReferences(content: string): string[] { + const connections: Set = new Set(); + + let match; + CONNECTION_GET_PATTERN.lastIndex = 0; + while ((match = CONNECTION_GET_PATTERN.exec(content)) !== null) { + connections.add(match[1]); + } + + return Array.from(connections); +} + +export function parseCode(content: string, language: "python" | "starlark" = "python"): ParseResult { + return { + entryPoints: parseEntryPoints(content, language), + imports: parseImports(content), + connectionReferences: parseConnectionReferences(content), + }; +} + +export function findEntryPointByName(entryPoints: ParsedEntryPoint[], name: string): ParsedEntryPoint | undefined { + return entryPoints.find((ep) => ep.name === name); +} + +export function getActiveEntryPoints(entryPoints: ParsedEntryPoint[]): ParsedEntryPoint[] { + return entryPoints.filter((ep) => ep.isActive); +} + +export function extractFunctionNameFromCall(call: string): { fileName: string; functionName: string } | null { + if (!call) { + return null; + } + + const parts = call.split(":"); + if (parts.length !== 2) { + return null; + } + + return { + fileName: parts[0], + functionName: parts[1], + }; +} + +export function formatEntryPointCall(fileName: string, functionName: string): string { + return `${fileName}:${functionName}`; +} diff --git a/src/utilities/connectionAnalyzer.ts b/src/utilities/connectionAnalyzer.ts new file mode 100644 index 0000000000..27e1ff7979 --- /dev/null +++ b/src/utilities/connectionAnalyzer.ts @@ -0,0 +1,309 @@ +export type OperationType = "read" | "write"; + +export interface ConnectionOperation { + type: OperationType; + functionName: string; + lineNumber: number; + method?: string; +} + +export interface ConnectionUsage { + connectionName: string; + functions: string[]; + operations: ConnectionOperation[]; + variableName?: string; +} + +export interface FileConnectionUsage { + filePath: string; + usages: ConnectionUsage[]; +} + +const CONNECTION_PATTERNS = { + akGetConnection: /(?:ak|autokitteh)\.get_connection\s*\(\s*["']([^"']+)["']\s*\)/g, + integrationConnection: /autokitteh\.(\w+)\.connection\s*\(\s*["']([^"']+)["']\s*\)/g, + directReference: /(\w+)\s*=\s*(?:ak|autokitteh)\.get_connection\s*\(\s*["']([^"']+)["']\s*\)/g, +}; + +const WRITE_METHODS = [ + "send", + "post", + "create", + "update", + "delete", + "write", + "put", + "patch", + "insert", + "add", + "set", + "upload", + "push", + "save", + "remove", + "chat_postMessage", + "chat_update", + "create_issue", + "update_issue", + "send_message", +]; + +const READ_METHODS = [ + "get", + "fetch", + "read", + "list", + "query", + "search", + "find", + "retrieve", + "download", + "conversations_list", + "users_list", + "get_issue", + "list_issues", + "get_message", +]; + +export function analyzeConnectionUsage(content: string, connectionNames: string[]): ConnectionUsage[] { + const usages: Map = new Map(); + const lines = content.split("\n"); + const connectionVariables: Map = new Map(); + + extractConnectionVariables(content, connectionVariables); + + connectionNames.forEach((connName) => { + usages.set(connName, { + connectionName: connName, + functions: [], + operations: [], + }); + }); + + const functionRanges = extractFunctionRanges(lines); + + lines.forEach((line, index) => { + const lineNumber = index + 1; + const currentFunction = findContainingFunction(lineNumber, functionRanges); + + connectionNames.forEach((connName) => { + if (line.includes(`"${connName}"`) || line.includes(`'${connName}'`)) { + const usage = usages.get(connName)!; + if (currentFunction && !usage.functions.includes(currentFunction)) { + usage.functions.push(currentFunction); + } + } + }); + + connectionVariables.forEach((connName, varName) => { + if (line.includes(varName) && !line.includes(`${varName} =`) && !line.includes(`${varName}=`)) { + const usage = usages.get(connName); + if (usage) { + usage.variableName = varName; + if (currentFunction && !usage.functions.includes(currentFunction)) { + usage.functions.push(currentFunction); + } + + const operation = detectOperation(line, varName, currentFunction || "unknown", lineNumber); + if (operation) { + usage.operations.push(operation); + } + } + } + }); + }); + + return Array.from(usages.values()).filter((usage) => usage.functions.length > 0 || usage.operations.length > 0); +} + +function extractConnectionVariables(content: string, connectionVariables: Map): void { + let match; + + CONNECTION_PATTERNS.directReference.lastIndex = 0; + while ((match = CONNECTION_PATTERNS.directReference.exec(content)) !== null) { + connectionVariables.set(match[1], match[2]); + } + + const integrationPattern = /(\w+)\s*=\s*autokitteh\.(\w+)\.connection\s*\(\s*["']([^"']+)["']\s*\)/g; + while ((match = integrationPattern.exec(content)) !== null) { + connectionVariables.set(match[1], match[3]); + } +} + +interface FunctionRange { + name: string; + startLine: number; + endLine: number; + indentLevel: number; +} + +function extractFunctionRanges(lines: string[]): FunctionRange[] { + const ranges: FunctionRange[] = []; + const functionPattern = /^(\s*)(?:async\s+)?def\s+(\w+)\s*\(/; + + for (let i = 0; i < lines.length; i++) { + const match = lines[i].match(functionPattern); + if (match) { + const indentLevel = match[1].length; + const funcName = match[2]; + const startLine = i + 1; + + let endLine = lines.length; + for (let j = i + 1; j < lines.length; j++) { + const nextLine = lines[j]; + if (nextLine.trim() === "") { + continue; + } + + const nextIndent = nextLine.match(/^(\s*)/)?.[1].length || 0; + if (nextIndent <= indentLevel && nextLine.trim() !== "") { + const isDecorator = nextLine.trim().startsWith("@"); + const isNextFunction = functionPattern.test(nextLine); + + if (isDecorator || isNextFunction) { + endLine = j; + break; + } + } + } + + ranges.push({ + name: funcName, + startLine, + endLine, + indentLevel, + }); + } + } + + return ranges; +} + +function findContainingFunction(lineNumber: number, ranges: FunctionRange[]): string | null { + for (const range of ranges) { + if (lineNumber >= range.startLine && lineNumber <= range.endLine) { + return range.name; + } + } + + return null; +} + +function detectOperation( + line: string, + varName: string, + functionName: string, + lineNumber: number +): ConnectionOperation | null { + const methodCallPattern = new RegExp(`${varName}\\.(\\w+)\\s*\\(`); + const match = line.match(methodCallPattern); + + if (match) { + const method = match[1]; + const type = determineOperationType(method); + + return { + type, + functionName, + lineNumber, + method, + }; + } + + return null; +} + +function determineOperationType(method: string): OperationType { + const lowerMethod = method.toLowerCase(); + + if (WRITE_METHODS.some((wm) => lowerMethod.includes(wm))) { + return "write"; + } + + if (READ_METHODS.some((rm) => lowerMethod.includes(rm))) { + return "read"; + } + + return "read"; +} + +export function getConnectionsUsedByFunction(usages: ConnectionUsage[], functionName: string): string[] { + return usages.filter((usage) => usage.functions.includes(functionName)).map((usage) => usage.connectionName); +} + +export function getFunctionsUsingConnection(usages: ConnectionUsage[], connectionName: string): string[] { + const usage = usages.find((u) => u.connectionName === connectionName); + + return usage?.functions || []; +} + +export function getOperationsByConnection(usages: ConnectionUsage[], connectionName: string): ConnectionOperation[] { + const usage = usages.find((u) => u.connectionName === connectionName); + + return usage?.operations || []; +} + +export function summarizeConnectionUsage(usages: ConnectionUsage[]): { + readOperations: number; + totalConnections: number; + totalOperations: number; + unusedConnections: string[]; + writeOperations: number; +} { + let totalOperations = 0; + let readOperations = 0; + let writeOperations = 0; + const unusedConnections: string[] = []; + + usages.forEach((usage) => { + if (usage.functions.length === 0 && usage.operations.length === 0) { + unusedConnections.push(usage.connectionName); + } + + usage.operations.forEach((op) => { + totalOperations++; + if (op.type === "read") { + readOperations++; + } else { + writeOperations++; + } + }); + }); + + return { + totalConnections: usages.length, + totalOperations, + readOperations, + writeOperations, + unusedConnections, + }; +} + +export function analyzeMultipleFiles( + files: { content: string; path: string }[], + connectionNames: string[] +): FileConnectionUsage[] { + return files.map((file) => ({ + filePath: file.path, + usages: analyzeConnectionUsage(file.content, connectionNames), + })); +} + +export function aggregateUsagesByConnection( + fileUsages: FileConnectionUsage[] +): Map { + const aggregated = new Map(); + + fileUsages.forEach((fileUsage) => { + fileUsage.usages.forEach((usage) => { + const existing = aggregated.get(usage.connectionName) || { files: [], totalOperations: 0 }; + if (!existing.files.includes(fileUsage.filePath)) { + existing.files.push(fileUsage.filePath); + } + existing.totalOperations += usage.operations.length; + aggregated.set(usage.connectionName, existing); + }); + }); + + return aggregated; +} diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 7d2a47c9c4..949e439b29 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -112,3 +112,59 @@ export type { SendMessageFn, ConnectionResolverFn } from "@utilities/iframeMessa export { getErrorMessage } from "@utilities/error.utils"; export { formatNumber, formatDuration } from "@utilities/formatDashboard.utils"; export { formatDate, formatDateShort } from "@utilities/formatDate.utils"; +export { + parseEntryPoints, + parseImports, + parseConnectionReferences, + parseCode, + findEntryPointByName, + getActiveEntryPoints, + extractFunctionNameFromCall, + formatEntryPointCall, +} from "@utilities/codeParser"; +export type { ParsedEntryPoint, ParseResult } from "@utilities/codeParser"; +export { + analyzeConnectionUsage, + getConnectionsUsedByFunction, + getFunctionsUsingConnection, + getOperationsByConnection, + summarizeConnectionUsage, + analyzeMultipleFiles, + aggregateUsagesByConnection, +} from "@utilities/connectionAnalyzer"; +export type { + ConnectionUsage, + ConnectionOperation, + FileConnectionUsage, + OperationType, +} from "@utilities/connectionAnalyzer"; +export { + buildWorkflowGraph, + getNodeById, + getNodesByType, + getTriggerNodesFromResult, + getCodeNodesFromResult, + getConnectionNodesFromResult, + createEdgeBetweenNodes, + getEdgesBySourceNode, + getEdgesByTargetNode, + getEdgesBetweenNodes, + getExecutionEdges, + getDataEdges, +} from "@utilities/workflowGraphBuilder"; +export type { + GraphBuildResult, + GraphBuildWarning, + GraphBuildWarningType, + GraphBuildContext, +} from "@utilities/workflowGraphBuilder"; +export { + applyAutoLayout, + DEFAULT_LAYOUT_CONFIG, + getLayoutBounds, + centerLayoutInViewport, + fitLayoutToViewport, + createCompactLayout, + createSpreadLayout, +} from "@utilities/workflowAutoLayout"; +export type { LayoutConfig } from "@utilities/workflowAutoLayout"; diff --git a/src/utilities/workflowAutoLayout.ts b/src/utilities/workflowAutoLayout.ts new file mode 100644 index 0000000000..e215856bc2 --- /dev/null +++ b/src/utilities/workflowAutoLayout.ts @@ -0,0 +1,424 @@ +import { XYPosition } from "@xyflow/react"; + +import { + WorkflowNode, + WorkflowEdge, + TriggerNode, + CodeNode, + ConnectionNode, +} from "@interfaces/components/workflowBuilder.interface"; + +export interface LayoutConfig { + nodeSpacing: { + horizontal: number; + vertical: number; + }; + layerGap: number; + startPosition: { + x: number; + y: number; + }; + alignment: "center" | "left"; + maxNodesPerRow: number; + nodeWidths: { + code: number; + connection: number; + trigger: number; + }; +} + +export const DEFAULT_LAYOUT_CONFIG: LayoutConfig = { + nodeSpacing: { horizontal: 220, vertical: 120 }, + layerGap: 180, + startPosition: { x: 100, y: 50 }, + alignment: "center", + maxNodesPerRow: 5, + nodeWidths: { + trigger: 160, + code: 200, + connection: 120, + }, +}; + +interface LayerInfo { + nodes: WorkflowNode[]; + yPosition: number; + layerWidth: number; +} + +export function applyAutoLayout( + nodes: WorkflowNode[], + edges: WorkflowEdge[], + config: LayoutConfig = DEFAULT_LAYOUT_CONFIG +): WorkflowNode[] { + if (nodes.length === 0) { + return nodes; + } + + const triggerNodes = nodes.filter((n) => n.type === "trigger") as TriggerNode[]; + const codeNodes = nodes.filter((n) => n.type === "code") as CodeNode[]; + const connectionNodes = nodes.filter((n) => n.type === "connection") as ConnectionNode[]; + + const orderedCodeNodes = orderCodeNodesByDependencies(codeNodes, edges, triggerNodes); + const orderedConnectionNodes = orderConnectionsByUsage(connectionNodes, edges, codeNodes); + + const layers: LayerInfo[] = [ + { + nodes: triggerNodes, + yPosition: config.startPosition.y, + layerWidth: calculateLayerWidth(triggerNodes.length, config, "trigger"), + }, + { + nodes: orderedCodeNodes, + yPosition: config.startPosition.y + config.layerGap, + layerWidth: calculateLayerWidth(orderedCodeNodes.length, config, "code"), + }, + { + nodes: orderedConnectionNodes, + yPosition: config.startPosition.y + config.layerGap * 2, + layerWidth: calculateLayerWidth(orderedConnectionNodes.length, config, "connection"), + }, + ]; + + const maxLayerWidth = Math.max(...layers.map((l) => l.layerWidth)); + + const positionedNodes: WorkflowNode[] = []; + + layers.forEach((layer) => { + const layerNodes = positionNodesInLayer(layer.nodes, layer.yPosition, maxLayerWidth, config); + positionedNodes.push(...layerNodes); + }); + + return optimizeEdgeCrossings(positionedNodes, edges, config); +} + +function calculateLayerWidth(nodeCount: number, config: LayoutConfig, _nodeType: string): number { + void _nodeType; + if (nodeCount === 0) { + return 0; + } + + const effectiveCount = Math.min(nodeCount, config.maxNodesPerRow); + + return (effectiveCount - 1) * config.nodeSpacing.horizontal; +} + +function positionNodesInLayer( + nodes: WorkflowNode[], + yPosition: number, + maxLayerWidth: number, + config: LayoutConfig +): WorkflowNode[] { + if (nodes.length === 0) { + return []; + } + + const positionedNodes: WorkflowNode[] = []; + const rows: WorkflowNode[][] = []; + + for (let i = 0; i < nodes.length; i += config.maxNodesPerRow) { + rows.push(nodes.slice(i, i + config.maxNodesPerRow)); + } + + rows.forEach((rowNodes, rowIndex) => { + const rowWidth = (rowNodes.length - 1) * config.nodeSpacing.horizontal; + let startX: number; + + if (config.alignment === "center") { + startX = config.startPosition.x + (maxLayerWidth - rowWidth) / 2; + } else { + startX = config.startPosition.x; + } + + const rowY = yPosition + rowIndex * config.nodeSpacing.vertical; + + rowNodes.forEach((node, index) => { + const position: XYPosition = { + x: startX + index * config.nodeSpacing.horizontal, + y: rowY, + }; + + positionedNodes.push({ + ...node, + position, + }); + }); + }); + + return positionedNodes; +} + +function orderCodeNodesByDependencies( + codeNodes: CodeNode[], + edges: WorkflowEdge[], + triggerNodes: TriggerNode[] +): CodeNode[] { + if (codeNodes.length <= 1) { + return codeNodes; + } + + const nodeScores = new Map(); + + codeNodes.forEach((node) => { + const incomingEdges = edges.filter((e) => e.target === node.id); + const triggersPointingToNode = incomingEdges.filter((e) => triggerNodes.some((t) => t.id === e.source)); + + const avgTriggerX = + triggersPointingToNode.length > 0 + ? triggersPointingToNode.reduce((sum, edge) => { + const trigger = triggerNodes.find((t) => t.id === edge.source); + + return sum + (trigger?.position?.x || 0); + }, 0) / triggersPointingToNode.length + : 0; + + const activeEntryPoints = node.data.entryPoints.filter((ep) => ep.isActive).length; + const connectionCount = node.data.usedConnections.length; + + const score = avgTriggerX * 1000 + activeEntryPoints * 100 + connectionCount; + nodeScores.set(node.id, score); + }); + + return [...codeNodes].sort((a, b) => { + const scoreA = nodeScores.get(a.id) || 0; + const scoreB = nodeScores.get(b.id) || 0; + + return scoreA - scoreB; + }); +} + +function orderConnectionsByUsage( + connectionNodes: ConnectionNode[], + edges: WorkflowEdge[], + codeNodes: CodeNode[] +): ConnectionNode[] { + if (connectionNodes.length <= 1) { + return connectionNodes; + } + + const nodeScores = new Map(); + + connectionNodes.forEach((node) => { + const incomingEdges = edges.filter((e) => e.target === node.id || e.source === node.id); + const connectedCodeNodes = incomingEdges + .map((e) => { + const codeNodeId = e.source === node.id ? e.target : e.source; + + return codeNodes.find((c) => c.id === codeNodeId); + }) + .filter(Boolean) as CodeNode[]; + + const avgCodeX = + connectedCodeNodes.length > 0 + ? connectedCodeNodes.reduce((sum, c) => sum + (c?.position?.x || 0), 0) / connectedCodeNodes.length + : 0; + + const usageCount = node.data.usedByFunctions.length; + + const score = avgCodeX * 1000 + usageCount * 100; + nodeScores.set(node.id, score); + }); + + return [...connectionNodes].sort((a, b) => { + const scoreA = nodeScores.get(a.id) || 0; + const scoreB = nodeScores.get(b.id) || 0; + + return scoreA - scoreB; + }); +} + +function optimizeEdgeCrossings(nodes: WorkflowNode[], edges: WorkflowEdge[], config: LayoutConfig): WorkflowNode[] { + const triggerNodes = nodes.filter((n) => n.type === "trigger"); + const codeNodes = nodes.filter((n) => n.type === "code"); + const connectionNodes = nodes.filter((n) => n.type === "connection"); + + const optimizedCodeNodes = minimizeCrossingsInLayer(codeNodes, edges, triggerNodes, "target", config); + + const optimizedConnectionNodes = minimizeCrossingsInLayer( + connectionNodes, + edges, + optimizedCodeNodes, + "target", + config + ); + + return [...triggerNodes, ...optimizedCodeNodes, ...optimizedConnectionNodes]; +} + +function minimizeCrossingsInLayer( + layerNodes: WorkflowNode[], + edges: WorkflowEdge[], + referenceNodes: WorkflowNode[], + nodeRole: "source" | "target", + config: LayoutConfig +): WorkflowNode[] { + if (layerNodes.length <= 1) { + return layerNodes; + } + + const nodeBarycenter = new Map(); + + layerNodes.forEach((node) => { + const connectedEdges = edges.filter((e) => + nodeRole === "target" ? e.target === node.id : e.source === node.id + ); + + const connectedRefNodes = connectedEdges + .map((e) => { + const refNodeId = nodeRole === "target" ? e.source : e.target; + + return referenceNodes.find((n) => n.id === refNodeId); + }) + .filter(Boolean) as WorkflowNode[]; + + if (connectedRefNodes.length > 0) { + const avgX = connectedRefNodes.reduce((sum, n) => sum + (n.position?.x || 0), 0) / connectedRefNodes.length; + nodeBarycenter.set(node.id, avgX); + } else { + nodeBarycenter.set(node.id, node.position?.x || 0); + } + }); + + const sortedNodes = [...layerNodes].sort((a, b) => { + const barycenterA = nodeBarycenter.get(a.id) || 0; + const barycenterB = nodeBarycenter.get(b.id) || 0; + + return barycenterA - barycenterB; + }); + + const layerWidth = (sortedNodes.length - 1) * config.nodeSpacing.horizontal; + const startX = + config.alignment === "center" + ? config.startPosition.x + (calculateMaxLayerWidth(layerNodes, config) - layerWidth) / 2 + : config.startPosition.x; + + return sortedNodes.map((node, index) => ({ + ...node, + position: { + x: startX + index * config.nodeSpacing.horizontal, + y: node.position?.y || 0, + }, + })); +} + +function calculateMaxLayerWidth(nodes: WorkflowNode[], config: LayoutConfig): number { + const triggerCount = nodes.filter((n) => n.type === "trigger").length; + const codeCount = nodes.filter((n) => n.type === "code").length; + const connectionCount = nodes.filter((n) => n.type === "connection").length; + + const maxCount = Math.max(triggerCount, codeCount, connectionCount); + + return (Math.min(maxCount, config.maxNodesPerRow) - 1) * config.nodeSpacing.horizontal; +} + +export function getLayoutBounds(nodes: WorkflowNode[]): { + height: number; + maxX: number; + maxY: number; + minX: number; + minY: number; + width: number; +} { + if (nodes.length === 0) { + return { minX: 0, minY: 0, maxX: 0, maxY: 0, width: 0, height: 0 }; + } + + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + nodes.forEach((node) => { + const x = node.position?.x || 0; + const y = node.position?.y || 0; + + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x + 200); + maxY = Math.max(maxY, y + 100); + }); + + return { + minX, + minY, + maxX, + maxY, + width: maxX - minX, + height: maxY - minY, + }; +} + +export function centerLayoutInViewport( + nodes: WorkflowNode[], + viewportWidth: number, + viewportHeight: number, + _padding: number = 50 +): WorkflowNode[] { + void _padding; + const bounds = getLayoutBounds(nodes); + + if (bounds.width === 0 || bounds.height === 0) { + return nodes; + } + + const targetCenterX = viewportWidth / 2; + const targetCenterY = viewportHeight / 2; + + const currentCenterX = bounds.minX + bounds.width / 2; + const currentCenterY = bounds.minY + bounds.height / 2; + + const offsetX = targetCenterX - currentCenterX; + const offsetY = targetCenterY - currentCenterY; + + return nodes.map((node) => ({ + ...node, + position: { + x: (node.position?.x || 0) + offsetX, + y: (node.position?.y || 0) + offsetY, + }, + })); +} + +export function fitLayoutToViewport( + nodes: WorkflowNode[], + viewportWidth: number, + viewportHeight: number, + padding: number = 50 +): { nodes: WorkflowNode[]; scale: number } { + const bounds = getLayoutBounds(nodes); + + if (bounds.width === 0 || bounds.height === 0) { + return { nodes, scale: 1 }; + } + + const availableWidth = viewportWidth - padding * 2; + const availableHeight = viewportHeight - padding * 2; + + const scaleX = availableWidth / bounds.width; + const scaleY = availableHeight / bounds.height; + const scale = Math.min(scaleX, scaleY, 1); + + const centeredNodes = centerLayoutInViewport(nodes, viewportWidth, viewportHeight, padding); + + return { nodes: centeredNodes, scale }; +} + +export function createCompactLayout(nodes: WorkflowNode[], edges: WorkflowEdge[]): WorkflowNode[] { + const compactConfig: LayoutConfig = { + ...DEFAULT_LAYOUT_CONFIG, + nodeSpacing: { horizontal: 180, vertical: 100 }, + layerGap: 140, + }; + + return applyAutoLayout(nodes, edges, compactConfig); +} + +export function createSpreadLayout(nodes: WorkflowNode[], edges: WorkflowEdge[]): WorkflowNode[] { + const spreadConfig: LayoutConfig = { + ...DEFAULT_LAYOUT_CONFIG, + nodeSpacing: { horizontal: 300, vertical: 160 }, + layerGap: 240, + }; + + return applyAutoLayout(nodes, edges, spreadConfig); +} diff --git a/src/utilities/workflowGraphBuilder.ts b/src/utilities/workflowGraphBuilder.ts new file mode 100644 index 0000000000..93119187a1 --- /dev/null +++ b/src/utilities/workflowGraphBuilder.ts @@ -0,0 +1,590 @@ +import { XYPosition } from "@xyflow/react"; + +import { TriggerTypes } from "@enums"; +import { + TriggerNode, + CodeNode, + ConnectionNode, + WorkflowNode, + WorkflowEdge, + ExecutionEdge, + DataEdge, + ProjectVariable, + TriggerType, + NodeStatus, + ConnectionStatus, + EntryPoint, + ExecutionEdgeData, + DataEdgeData, +} from "@interfaces/components/workflowBuilder.interface"; +import { ExistingCodeFile, ProjectWorkflowData } from "@services/workflowBuilder.service"; +import { Integrations, IntegrationsMap } from "@src/enums/components/connection.enum"; +import { Trigger, Connection, Variable } from "@type/models"; +import { parseEntryPoints, ParsedEntryPoint } from "@utilities/codeParser"; +import { analyzeConnectionUsage, ConnectionUsage } from "@utilities/connectionAnalyzer"; + +export type GraphBuildWarningType = + | "orphan_trigger" + | "unused_connection" + | "missing_entry_point" + | "circular_dependency" + | "invalid_connection_ref"; + +export interface GraphBuildWarning { + type: GraphBuildWarningType; + message: string; + affectedIds: string[]; +} + +export interface GraphBuildResult { + nodes: WorkflowNode[]; + edges: WorkflowEdge[]; + variables: ProjectVariable[]; + warnings: GraphBuildWarning[]; +} + +export interface GraphBuildContext { + codeNodesByFile: Map; + connectionNodesByName: Map; + triggerNodes: TriggerNode[]; + activeFunctions: Set; + connectionUsageMap: Map; +} + +const defaultPosition: XYPosition = { x: 0, y: 0 }; + +export function buildWorkflowGraph(data: ProjectWorkflowData): GraphBuildResult { + const warnings: GraphBuildWarning[] = []; + const context: GraphBuildContext = { + codeNodesByFile: new Map(), + connectionNodesByName: new Map(), + triggerNodes: [], + activeFunctions: new Set(), + connectionUsageMap: new Map(), + }; + + const activeFunctions = extractActiveFunctions(data.triggers); + context.activeFunctions = activeFunctions; + + const codeNodes = createCodeNodes(data.files, activeFunctions, data.connections, context); + const connectionNodes = createConnectionNodes(data.connections, context); + const triggerNodes = createTriggerNodes(data.triggers, context, warnings); + const variables = convertVariables(data.variables); + + detectWarnings(context, warnings); + + const nodes: WorkflowNode[] = [...triggerNodes, ...codeNodes, ...connectionNodes]; + + const executionEdges = createExecutionEdges(triggerNodes, codeNodes, data.triggers); + const dataEdges = createDataEdges(codeNodes, connectionNodes, context); + const edges: WorkflowEdge[] = [...executionEdges, ...dataEdges]; + + return { + nodes, + edges, + variables, + warnings, + }; +} + +function extractActiveFunctions(triggers: Trigger[]): Set { + const activeFunctions = new Set(); + + triggers.forEach((trigger) => { + if (trigger.entryFunction) { + activeFunctions.add(trigger.entryFunction); + } + + if (trigger.path && trigger.entryFunction) { + const fullPath = `${trigger.path}:${trigger.entryFunction}`; + activeFunctions.add(fullPath); + } + }); + + return activeFunctions; +} + +function createCodeNodes( + files: ExistingCodeFile[], + activeFunctions: Set, + connections: Connection[], + context: GraphBuildContext +): CodeNode[] { + const connectionNames = connections.map((c) => c.name); + + return files.map((file) => { + const entryPoints = parseFileEntryPoints(file, activeFunctions); + const usedConnections = analyzeFileConnections(file, connectionNames, context); + + const node: CodeNode = { + id: `code-${file.path.replace(/\//g, "-").replace(/\./g, "_")}`, + type: "code", + position: defaultPosition, + data: { + fileName: file.name, + filePath: file.path, + language: file.language, + entryPoints, + usedConnections, + status: determineCodeNodeStatus(entryPoints), + }, + }; + + context.codeNodesByFile.set(file.path, node); + context.codeNodesByFile.set(file.name, node); + + return node; + }); +} + +function parseFileEntryPoints(file: ExistingCodeFile, activeFunctions: Set): EntryPoint[] { + if (file.exports && file.exports.length > 0) { + return file.exports.map((exportName, index) => ({ + name: exportName, + lineNumber: index + 1, + isActive: activeFunctions.has(exportName) || activeFunctions.has(`${file.path}:${exportName}`), + })); + } + + if (file.content) { + const parsed = parseEntryPoints(file.content, file.language); + + return parsed.map((ep: ParsedEntryPoint) => ({ + name: ep.name, + lineNumber: ep.lineNumber, + isActive: activeFunctions.has(ep.name) || activeFunctions.has(`${file.path}:${ep.name}`), + })); + } + + return []; +} + +function analyzeFileConnections( + file: ExistingCodeFile, + connectionNames: string[], + context: GraphBuildContext +): string[] { + if (!file.content) { + return []; + } + + const usages = analyzeConnectionUsage(file.content, connectionNames); + context.connectionUsageMap.set(file.path, usages); + + return usages.map((u) => u.connectionName); +} + +function determineCodeNodeStatus(entryPoints: EntryPoint[]): NodeStatus { + const hasActiveEntryPoints = entryPoints.some((ep) => ep.isActive); + + if (hasActiveEntryPoints) { + return "active"; + } + + if (entryPoints.length > 0) { + return "configured"; + } + + return "draft"; +} + +function createConnectionNodes(connections: Connection[], context: GraphBuildContext): ConnectionNode[] { + return connections.map((connection) => { + const integration = mapIntegrationName(connection.integrationUniqueName || connection.integrationId); + const displayName = getIntegrationDisplayName(integration, connection.integrationName); + const usedByFunctions = findFunctionsUsingConnection(connection.name, context); + + const node: ConnectionNode = { + id: `connection-${connection.connectionId}`, + type: "connection", + position: defaultPosition, + data: { + connectionName: connection.name, + integration, + displayName, + status: mapConnectionStatus(connection.status), + usedByFunctions, + }, + }; + + context.connectionNodesByName.set(connection.name, node); + + return node; + }); +} + +function mapIntegrationName(integrationName?: string): Integrations { + if (!integrationName) { + return Integrations.slack; + } + + const normalizedName = integrationName.toLowerCase().replace(/-/g, "_"); + + if (normalizedName in Integrations) { + return normalizedName as Integrations; + } + + const integrationValues = Object.values(Integrations); + const match = integrationValues.find((v) => normalizedName.includes(v) || v.includes(normalizedName)); + + return match || Integrations.slack; +} + +function getIntegrationDisplayName(integration: Integrations, fallbackName?: string): string { + const integrationInfo = IntegrationsMap[integration]; + + return integrationInfo?.label || fallbackName || integration; +} + +function mapConnectionStatus(status: string): ConnectionStatus { + const statusMap: Record = { + ok: "connected", + valid: "connected", + connected: "connected", + active: "active", + error: "error", + invalid: "error", + warning: "disconnected", + disconnected: "disconnected", + }; + + return statusMap[status?.toLowerCase()] || "disconnected"; +} + +function findFunctionsUsingConnection(connectionName: string, context: GraphBuildContext): string[] { + const functions: string[] = []; + + context.connectionUsageMap.forEach((usages) => { + const usage = usages.find((u) => u.connectionName === connectionName); + if (usage) { + functions.push(...usage.functions); + } + }); + + return [...new Set(functions)]; +} + +function createTriggerNodes( + triggers: Trigger[], + context: GraphBuildContext, + warnings: GraphBuildWarning[] +): TriggerNode[] { + return triggers.map((trigger) => { + const triggerType = mapTriggerType(trigger.sourceType); + const call = formatEntryPointCall(trigger.path, trigger.entryFunction); + const status = determineTriggerStatus(trigger, context, warnings); + + const node: TriggerNode = { + id: `trigger-${trigger.triggerId}`, + type: "trigger", + position: defaultPosition, + data: { + type: triggerType, + name: trigger.name || `${triggerType} trigger`, + schedule: trigger.schedule, + webhookPath: trigger.webhookSlug, + connectionRef: trigger.connectionId, + eventType: trigger.eventType, + eventFilter: trigger.filter, + call, + isDurable: trigger.isDurable ?? true, + status, + }, + }; + + context.triggerNodes.push(node); + + return node; + }); +} + +function mapTriggerType(sourceType?: TriggerTypes): TriggerType { + if (!sourceType) { + return "event"; + } + + const typeMap: Record = { + schedule: "schedule", + webhook: "webhook", + connection: "event", + }; + + return typeMap[sourceType] || "event"; +} + +function formatEntryPointCall(path?: string, functionName?: string): string { + if (!functionName) { + return ""; + } + + if (path) { + return `${path}:${functionName}`; + } + + return functionName; +} + +function determineTriggerStatus( + trigger: Trigger, + context: GraphBuildContext, + warnings: GraphBuildWarning[] +): NodeStatus { + if (!trigger.entryFunction) { + return "draft"; + } + + const targetFile = trigger.path; + if (targetFile && !context.codeNodesByFile.has(targetFile)) { + warnings.push({ + type: "orphan_trigger", + message: `Trigger "${trigger.name || trigger.triggerId}" references non-existent file "${targetFile}"`, + affectedIds: [trigger.triggerId || ""], + }); + + return "error"; + } + + if (targetFile) { + const codeNode = context.codeNodesByFile.get(targetFile); + if (codeNode) { + const hasFunction = codeNode.data.entryPoints.some((ep) => ep.name === trigger.entryFunction); + if (!hasFunction) { + warnings.push({ + type: "missing_entry_point", + message: `Trigger "${trigger.name || trigger.triggerId}" references non-existent function "${trigger.entryFunction}" in "${targetFile}"`, + affectedIds: [trigger.triggerId || ""], + }); + + return "error"; + } + } + } + + if (trigger.connectionId && !context.connectionNodesByName.has(trigger.connectionId)) { + warnings.push({ + type: "invalid_connection_ref", + message: `Trigger "${trigger.name || trigger.triggerId}" references unknown connection "${trigger.connectionId}"`, + affectedIds: [trigger.triggerId || ""], + }); + } + + return "configured"; +} + +function convertVariables(variables: Variable[]): ProjectVariable[] { + return variables.map((variable, index) => ({ + id: `var-${variable.scopeId || "project"}-${index}`, + name: variable.name, + value: variable.isSecret ? "••••••••" : variable.value, + isSecret: variable.isSecret, + })); +} + +function detectWarnings(context: GraphBuildContext, warnings: GraphBuildWarning[]): void { + context.connectionNodesByName.forEach((connectionNode, connectionName) => { + if (connectionNode.data.usedByFunctions.length === 0) { + const isUsedByTrigger = context.triggerNodes.some( + (t) => t.data.connectionRef === connectionName || t.data.connectionRef === connectionNode.id + ); + + if (!isUsedByTrigger) { + warnings.push({ + type: "unused_connection", + message: `Connection "${connectionName}" is not used by any code or trigger`, + affectedIds: [connectionNode.id], + }); + } + } + }); +} + +export function getNodeById(nodes: WorkflowNode[], nodeId: string): WorkflowNode | undefined { + return nodes.find((n) => n.id === nodeId); +} + +export function getNodesByType(nodes: WorkflowNode[], type: string): T[] { + return nodes.filter((n) => n.type === type) as T[]; +} + +export function getTriggerNodesFromResult(result: GraphBuildResult): TriggerNode[] { + return getNodesByType(result.nodes, "trigger"); +} + +export function getCodeNodesFromResult(result: GraphBuildResult): CodeNode[] { + return getNodesByType(result.nodes, "code"); +} + +export function getConnectionNodesFromResult(result: GraphBuildResult): ConnectionNode[] { + return getNodesByType(result.nodes, "connection"); +} + +function createExecutionEdges( + triggerNodes: TriggerNode[], + codeNodes: CodeNode[], + triggers: Trigger[] +): ExecutionEdge[] { + const edges: ExecutionEdge[] = []; + + triggerNodes.forEach((triggerNode, index) => { + const trigger = triggers[index]; + if (!trigger?.path && !trigger?.entryFunction) { + return; + } + + const targetFile = trigger.path; + const functionName = trigger.entryFunction; + + let targetNode: CodeNode | undefined; + + if (targetFile) { + targetNode = codeNodes.find((n) => n.data.filePath === targetFile || n.data.fileName === targetFile); + } + + if (!targetNode && functionName) { + targetNode = codeNodes.find((n) => n.data.entryPoints.some((ep) => ep.name === functionName)); + } + + if (!targetNode) { + return; + } + + const edge: ExecutionEdge = { + id: `edge-exec-${triggerNode.id}-${targetNode.id}`, + source: triggerNode.id, + target: targetNode.id, + sourceHandle: "bottom", + targetHandle: "top", + type: "execution", + animated: true, + data: { + type: "execution", + functionCall: functionName || "", + isActive: triggerNode.data.status !== "error" && triggerNode.data.status !== "draft", + } as ExecutionEdgeData, + }; + + edges.push(edge); + }); + + return edges; +} + +function createDataEdges( + codeNodes: CodeNode[], + connectionNodes: ConnectionNode[], + context: GraphBuildContext +): DataEdge[] { + const edges: DataEdge[] = []; + const createdEdges = new Set(); + + codeNodes.forEach((codeNode) => { + const fileUsages = context.connectionUsageMap.get(codeNode.data.filePath) || []; + + codeNode.data.usedConnections.forEach((connectionName) => { + const connectionNode = connectionNodes.find((n) => n.data.connectionName === connectionName); + + if (!connectionNode) { + return; + } + + const edgeKey = `${codeNode.id}-${connectionNode.id}`; + if (createdEdges.has(edgeKey)) { + return; + } + + const usage = fileUsages.find((u) => u.connectionName === connectionName); + const operations: DataEdgeData["operations"] = + usage?.operations.map((op) => ({ + functionName: op.functionName, + lineNumber: op.lineNumber, + operationType: op.type, + })) || []; + + const edge: DataEdge = { + id: `edge-data-${codeNode.id}-${connectionNode.id}`, + source: codeNode.id, + target: connectionNode.id, + sourceHandle: "right", + targetHandle: "left", + type: "data", + data: { + type: "data", + operations, + } as DataEdgeData, + }; + + edges.push(edge); + createdEdges.add(edgeKey); + }); + }); + + return edges; +} + +export function createEdgeBetweenNodes( + sourceId: string, + targetId: string, + sourceType: string, + targetType: string +): WorkflowEdge | null { + const timestamp = Date.now(); + + if (sourceType === "trigger" && targetType === "code") { + return { + id: `edge-exec-${sourceId}-${targetId}-${timestamp}`, + source: sourceId, + target: targetId, + sourceHandle: "bottom", + targetHandle: "top", + type: "execution", + animated: true, + data: { + type: "execution", + functionCall: "", + isActive: false, + } as ExecutionEdgeData, + } as ExecutionEdge; + } + + if ( + (sourceType === "code" && targetType === "connection") || + (sourceType === "connection" && targetType === "code") + ) { + return { + id: `edge-data-${sourceId}-${targetId}-${timestamp}`, + source: sourceId, + target: targetId, + sourceHandle: sourceType === "code" ? "right" : "left", + targetHandle: sourceType === "code" ? "left" : "right", + type: "data", + data: { + type: "data", + operations: [], + } as DataEdgeData, + } as DataEdge; + } + + return null; +} + +export function getEdgesBySourceNode(edges: WorkflowEdge[], sourceId: string): WorkflowEdge[] { + return edges.filter((e) => e.source === sourceId); +} + +export function getEdgesByTargetNode(edges: WorkflowEdge[], targetId: string): WorkflowEdge[] { + return edges.filter((e) => e.target === targetId); +} + +export function getEdgesBetweenNodes(edges: WorkflowEdge[], nodeId1: string, nodeId2: string): WorkflowEdge[] { + return edges.filter( + (e) => (e.source === nodeId1 && e.target === nodeId2) || (e.source === nodeId2 && e.target === nodeId1) + ); +} + +export function getExecutionEdges(edges: WorkflowEdge[]): ExecutionEdge[] { + return edges.filter((e) => e.type === "execution") as ExecutionEdge[]; +} + +export function getDataEdges(edges: WorkflowEdge[]): DataEdge[] { + return edges.filter((e) => e.type === "data") as DataEdge[]; +} diff --git a/tailwind.config.cjs b/tailwind.config.cjs index f92ab23235..06f8fb2170 100644 --- a/tailwind.config.cjs +++ b/tailwind.config.cjs @@ -5,6 +5,8 @@ const plugin = require("tailwindcss/plugin"); module.exports = { content: ["./src/**/*.{js,jsx,ts,tsx}", "./node_modules/@tremor/**/*.{js,ts,jsx,tsx}"], safelist: [ + "nodrag", + "nopan", { pattern: /^(bg-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, @@ -151,28 +153,43 @@ module.exports = { white: "#ffffff", black: "#000000", blue: { + 300: "#93c5fd", 400: "#60a5fa", 500: "#3b82f6", 600: "#2563eb", + 900: "#1e3a8a", }, yellow: { 500: "#eab308", }, orange: { + 300: "#fdba74", + 400: "#fb923c", 500: "#f59e42", }, amber: { + 300: "#fcd34d", 400: "#fbbf24", 500: "#f59e0b", 600: "#d97706", + 900: "#78350f", }, green: { 200: "#E8FEBE", + 300: "#86efac", 400: "#C8F46C", 500: "#86D13F", 600: "#7FAE3C", 700: "#22c55e", 800: "#BCF870", + 900: "#14532d", + }, + purple: { + 300: "#d8b4fe", + 400: "#c084fc", + 500: "#a855f7", + 600: "#9333ea", + 900: "#581c87", }, emerald: { 50: "#ecfdf5",