From e43c53723ee16ead82da5d2db0e0f5080683a446 Mon Sep 17 00:00:00 2001 From: Ronen Mars Date: Sun, 28 Dec 2025 10:08:55 +0200 Subject: [PATCH 1/5] feat: integrations dnd canvas --- eslint.config.mjs | 2 +- package-lock.json | 67 ++++++ package.json | 1 + src/components/organisms/index.ts | 7 + .../organisms/workflowBuilder/codeEdge.tsx | 119 ++++++++++ .../workflowBuilder/connectionEditorModal.tsx | 213 ++++++++++++++++++ .../workflowBuilder/deleteEdgeModal.tsx | 60 +++++ .../workflowBuilder/deleteNodeModal.tsx | 59 +++++ .../organisms/workflowBuilder/index.ts | 5 + .../workflowBuilder/integrationNode.tsx | 62 +++++ .../workflowBuilder/integrationsSidebar.tsx | 65 ++++++ .../workflowBuilder/workflowBuilder.tsx | 54 +++++ .../workflowBuilder/workflowCanvas.tsx | 174 ++++++++++++++ src/enums/components/modal.enum.ts | 3 + src/interfaces/components/index.ts | 7 + .../components/workflowBuilder.interface.ts | 34 +++ src/locales/en/index.ts | 2 + .../en/workflowBuilder/translation.json | 35 +++ src/routes.tsx | 14 ++ src/store/index.ts | 1 + src/store/useWorkflowBuilderStore.ts | 69 ++++++ tailwind.config.cjs | 2 + 22 files changed, 1054 insertions(+), 1 deletion(-) create mode 100644 src/components/organisms/workflowBuilder/codeEdge.tsx create mode 100644 src/components/organisms/workflowBuilder/connectionEditorModal.tsx create mode 100644 src/components/organisms/workflowBuilder/deleteEdgeModal.tsx create mode 100644 src/components/organisms/workflowBuilder/deleteNodeModal.tsx create mode 100644 src/components/organisms/workflowBuilder/index.ts create mode 100644 src/components/organisms/workflowBuilder/integrationNode.tsx create mode 100644 src/components/organisms/workflowBuilder/integrationsSidebar.tsx create mode 100644 src/components/organisms/workflowBuilder/workflowBuilder.tsx create mode 100644 src/components/organisms/workflowBuilder/workflowCanvas.tsx create mode 100644 src/interfaces/components/workflowBuilder.interface.ts create mode 100644 src/locales/en/workflowBuilder/translation.json create mode 100644 src/store/useWorkflowBuilderStore.ts 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/organisms/index.ts b/src/components/organisms/index.ts index ab0250279f..e21285b086 100644 --- a/src/components/organisms/index.ts +++ b/src/components/organisms/index.ts @@ -28,3 +28,10 @@ export { FileNode } from "@components/organisms/files/fileNode"; export { OrgConnectionsTable } from "@components/organisms/orgConnections"; export { OrgConnectionsDrawer } from "@components/organisms/orgConnectionsDrawer"; export { ProjectConfigurationDrawer } from "@components/organisms/configuration/configrationDrawer"; +export { + WorkflowBuilder, + WorkflowCanvas, + IntegrationsSidebar, + IntegrationNode, + ConnectionEditorModal, +} from "@components/organisms/workflowBuilder"; diff --git a/src/components/organisms/workflowBuilder/codeEdge.tsx b/src/components/organisms/workflowBuilder/codeEdge.tsx new file mode 100644 index 0000000000..22eeb05985 --- /dev/null +++ b/src/components/organisms/workflowBuilder/codeEdge.tsx @@ -0,0 +1,119 @@ +import React, { useCallback, useMemo, useState } from "react"; + +import { BaseEdge, EdgeLabelRenderer, getBezierPath, Position } from "@xyflow/react"; +import { LuCode, LuX } from "react-icons/lu"; + +import { ModalName } from "@enums/components"; +import { cn } from "@src/utilities"; +import { useModalStore } from "@store/useModalStore"; + +interface CodeEdgeProps { + id: string; + sourceX: number; + sourceY: number; + targetX: number; + targetY: number; + sourcePosition: Position; + targetPosition: Position; + style?: React.CSSProperties; + markerEnd?: string; + data?: { code?: string; eventType?: string }; +} + +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 hasCode = data?.code && data.code.trim().length > 0; + + 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 codeIconContainerClass = useMemo( + () => + cn( + "flex size-8 cursor-pointer items-center justify-center rounded-full border-2 transition-all duration-200", + isHovered || hasCode + ? "scale-100 border-green-500 bg-gray-900 opacity-100" + : "scale-75 border-gray-600 bg-gray-900 opacity-0" + ), + [isHovered, hasCode] + ); + + const codeIconClass = useMemo(() => cn("size-4", hasCode ? "text-green-500" : "text-gray-400"), [hasCode]); + + return ( + <> + + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + stroke="transparent" + strokeWidth={20} + style={{ cursor: "pointer" }} + /> + +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + style={{ + transform: `translate(-50%, calc(-50% - 12px)) translate(${labelX}px, ${labelY}px)`, + }} + > + +
+ +
+
+
+ + ); +}; diff --git a/src/components/organisms/workflowBuilder/connectionEditorModal.tsx b/src/components/organisms/workflowBuilder/connectionEditorModal.tsx new file mode 100644 index 0000000000..ededf1e0a6 --- /dev/null +++ b/src/components/organisms/workflowBuilder/connectionEditorModal.tsx @@ -0,0 +1,213 @@ +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 { eventTypesPerIntegration } from "@src/constants/triggers"; +import { ModalName } from "@src/enums"; +import { Integrations } from "@src/enums/components"; +import { SelectOption } from "@src/interfaces/components"; +import { useModalStore } from "@store/useModalStore"; +import { useWorkflowBuilderStore } from "@store/useWorkflowBuilderStore"; + +import { Button, Spinner, Typography } from "@components/atoms"; +import { Modal } from "@components/molecules"; +import { Select } from "@components/molecules/select"; + +const defaultCode = `# Define the connection logic between integrations +# This code will be executed when data flows through this connection + +def on_connection(source_data): + """ + Process data from the source integration. + + Args: + source_data: Data received from the source integration + + Returns: + Processed data to send to the target integration + """ + # Transform or process the data as needed + return source_data +`; + +export const ConnectionEditorModal = () => { + const { t } = useTranslation("workflowBuilder"); + const { closeModal, getModalData } = useModalStore(); + const { edges, updateEdgeCode, updateEdgeEventType } = useWorkflowBuilderStore(); + + const editorRef = useRef(null); + const [code, setCode] = useState(""); + const [selectedEventType, setSelectedEventType] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + 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 existingCode = edge?.data?.code || defaultCode; + const existingEventType = edge?.data?.eventType; + setCode(existingCode); + if (existingEventType) { + setSelectedEventType({ value: existingEventType, label: existingEventType }); + } else { + setSelectedEventType(null); + } + setIsLoading(false); + } + }, [edgeId, edges]); + + 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); + } + } + disposeEditor(); + closeModal(ModalName.connectionCodeEditor); + }, [edgeId, code, selectedEventType, updateEdgeCode, updateEdgeEventType, 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} + /> +
+
+
+ + +
+ + ))} + + )} + + + )}
diff --git a/src/components/organisms/workflowBuilder/integrationNode.tsx b/src/components/organisms/workflowBuilder/integrationNode.tsx index e9be77af68..6a19d73652 100644 --- a/src/components/organisms/workflowBuilder/integrationNode.tsx +++ b/src/components/organisms/workflowBuilder/integrationNode.tsx @@ -1,13 +1,14 @@ import React, { memo, useCallback, useMemo, useState } from "react"; import { Handle, Position, NodeProps, Node } from "@xyflow/react"; -import { LuX } from "react-icons/lu"; +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"; @@ -16,8 +17,10 @@ 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) => { @@ -36,17 +39,37 @@ const IntegrationNodeComponent = ({ id, data }: IntegrationNodeProps) => { [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}
diff --git a/src/components/organisms/workflowBuilder/workflowCanvas.tsx b/src/components/organisms/workflowBuilder/workflowCanvas.tsx index 71eff91ca3..e37c17826b 100644 --- a/src/components/organisms/workflowBuilder/workflowCanvas.tsx +++ b/src/components/organisms/workflowBuilder/workflowCanvas.tsx @@ -83,13 +83,12 @@ export const WorkflowCanvas = () => { sourceHandle: connection.sourceHandle, targetHandle: connection.targetHandle, type: "code", - animated: true, - style: { stroke: "#22c55e", strokeWidth: 2 }, + animated: false, markerEnd: { type: MarkerType.ArrowClosed, - color: "#22c55e", + color: "#6b7280", }, - data: { code: "", eventType: "" }, + data: { code: "", eventType: "", variables: [], status: "draft" }, }; const updatedEdges = addEdge(newEdge as Edge, storeEdges as Edge[]) as WorkflowEdge[]; diff --git a/src/interfaces/components/workflowBuilder.interface.ts b/src/interfaces/components/workflowBuilder.interface.ts index c76885b278..14861a5c1e 100644 --- a/src/interfaces/components/workflowBuilder.interface.ts +++ b/src/interfaces/components/workflowBuilder.interface.ts @@ -5,13 +5,24 @@ import { Integrations } from "@src/enums/components"; export interface IntegrationNodeData extends Record { integration: Integrations; label: string; + isTrigger?: boolean; } export type WorkflowNode = Node; +export interface WorkflowEdgeVariable { + key: string; + value: string; + isSecret?: boolean; +} + +export type EdgeStatus = "draft" | "configured" | "active" | "error"; + export interface WorkflowEdgeData extends Record { code: string; eventType: string; + variables: WorkflowEdgeVariable[]; + status: EdgeStatus; } export type WorkflowEdge = Edge; @@ -20,6 +31,7 @@ export interface WorkflowBuilderState { nodes: WorkflowNode[]; edges: WorkflowEdge[]; selectedEdgeId: string | null; + triggerNodeId: string | null; addNode: (node: WorkflowNode) => void; removeNode: (nodeId: string) => void; updateNodePosition: (nodeId: string, position: { x: number; y: number }) => void; @@ -28,7 +40,10 @@ export interface WorkflowBuilderState { removeEdge: (edgeId: string) => void; updateEdgeCode: (edgeId: string, code: string) => void; updateEdgeEventType: (edgeId: string, eventType: string) => void; + updateEdgeVariables: (edgeId: string, variables: WorkflowEdgeVariable[]) => void; + updateEdgeStatus: (edgeId: string, status: EdgeStatus) => void; setEdges: (edges: WorkflowEdge[]) => void; setSelectedEdgeId: (edgeId: string | null) => void; + setTriggerNodeId: (nodeId: string | null) => void; clearWorkflow: () => void; } diff --git a/src/store/useWorkflowBuilderStore.ts b/src/store/useWorkflowBuilderStore.ts index 537094e900..dca25eeb3e 100644 --- a/src/store/useWorkflowBuilderStore.ts +++ b/src/store/useWorkflowBuilderStore.ts @@ -3,12 +3,26 @@ import { persist } from "zustand/middleware"; import { shallow } from "zustand/shallow"; import { createWithEqualityFn as create } from "zustand/traditional"; -import { WorkflowBuilderState, WorkflowEdge, WorkflowNode } from "@interfaces/components/workflowBuilder.interface"; +import { + EdgeStatus, + WorkflowBuilderState, + WorkflowEdge, + WorkflowEdgeVariable, + WorkflowNode, +} from "@interfaces/components/workflowBuilder.interface"; + +const computeEdgeStatus = (code: string, eventType: string): EdgeStatus => { + const hasCode = code && code.trim().length > 0; + const hasEventType = eventType && eventType.trim().length > 0; + if (hasCode && hasEventType) return "configured"; + return "draft"; +}; const store: StateCreator = (set) => ({ nodes: [], edges: [], selectedEdgeId: null, + triggerNodeId: null, addNode: (node: WorkflowNode) => set((state) => ({ @@ -16,10 +30,14 @@ const store: StateCreator = (set) => ({ })), removeNode: (nodeId: string) => - set((state) => ({ - nodes: state.nodes.filter((node) => node.id !== nodeId), - edges: state.edges.filter((edge) => edge.source !== nodeId && edge.target !== nodeId), - })), + set((state) => { + const newTriggerNodeId = state.triggerNodeId === nodeId ? null : state.triggerNodeId; + return { + nodes: state.nodes.filter((node) => node.id !== nodeId), + edges: state.edges.filter((edge) => edge.source !== nodeId && edge.target !== nodeId), + triggerNodeId: newTriggerNodeId, + }; + }), updateNodePosition: (nodeId: string, position: { x: number; y: number }) => set((state) => ({ @@ -29,27 +47,97 @@ const store: StateCreator = (set) => ({ setNodes: (nodes: WorkflowNode[]) => set({ nodes }), addEdge: (edge: WorkflowEdge) => + set((state) => { + const isFirstEdgeFromSource = !state.edges.some((e) => e.source === edge.source); + const shouldSetTrigger = isFirstEdgeFromSource && !state.triggerNodeId; + return { + edges: [...state.edges, edge], + triggerNodeId: shouldSetTrigger ? edge.source : state.triggerNodeId, + }; + }), + + removeEdge: (edgeId: string) => + set((state) => { + const edgeToRemove = state.edges.find((e) => e.id === edgeId); + const remainingEdges = state.edges.filter((edge) => edge.id !== edgeId); + const sourceStillHasEdges = remainingEdges.some((e) => e.source === edgeToRemove?.source); + const newTriggerNodeId = + state.triggerNodeId === edgeToRemove?.source && !sourceStillHasEdges ? null : state.triggerNodeId; + return { + edges: remainingEdges, + selectedEdgeId: state.selectedEdgeId === edgeId ? null : state.selectedEdgeId, + triggerNodeId: newTriggerNodeId, + }; + }), + + updateEdgeCode: (edgeId: string, code: string) => set((state) => ({ - edges: [...state.edges, edge], + edges: state.edges.map((edge) => { + if (edge.id !== edgeId) return edge; + const eventType = edge.data?.eventType || ""; + const variables = edge.data?.variables || []; + return { + ...edge, + data: { + code, + eventType, + variables, + status: computeEdgeStatus(code, eventType), + }, + }; + }), })), - removeEdge: (edgeId: string) => + updateEdgeEventType: (edgeId: string, eventType: string) => set((state) => ({ - edges: state.edges.filter((edge) => edge.id !== edgeId), - selectedEdgeId: state.selectedEdgeId === edgeId ? null : state.selectedEdgeId, + edges: state.edges.map((edge) => { + if (edge.id !== edgeId) return edge; + const code = edge.data?.code || ""; + const variables = edge.data?.variables || []; + return { + ...edge, + data: { + code, + eventType, + variables, + status: computeEdgeStatus(code, eventType), + }, + }; + }), })), - updateEdgeCode: (edgeId: string, code: string) => + updateEdgeVariables: (edgeId: string, variables: WorkflowEdgeVariable[]) => set((state) => ({ - edges: state.edges.map((edge) => - edge.id === edgeId ? { ...edge, data: { code, eventType: edge.data?.eventType || "" } } : edge - ), + edges: state.edges.map((edge) => { + if (edge.id !== edgeId) return edge; + return { + ...edge, + data: { + ...edge.data, + code: edge.data?.code || "", + eventType: edge.data?.eventType || "", + status: edge.data?.status || "draft", + variables, + }, + }; + }), })), - updateEdgeEventType: (edgeId: string, eventType: string) => + updateEdgeStatus: (edgeId: string, status: EdgeStatus) => set((state) => ({ edges: state.edges.map((edge) => - edge.id === edgeId ? { ...edge, data: { code: edge.data?.code || "", eventType } } : edge + edge.id === edgeId + ? { + ...edge, + data: { + ...edge.data, + code: edge.data?.code || "", + eventType: edge.data?.eventType || "", + variables: edge.data?.variables || [], + status, + }, + } + : edge ), })), @@ -57,7 +145,16 @@ const store: StateCreator = (set) => ({ setSelectedEdgeId: (edgeId: string | null) => set({ selectedEdgeId: edgeId }), - clearWorkflow: () => set({ nodes: [], edges: [], selectedEdgeId: null }), + setTriggerNodeId: (nodeId: string | null) => + set((state) => ({ + triggerNodeId: nodeId, + nodes: state.nodes.map((node) => ({ + ...node, + data: { ...node.data, isTrigger: node.id === nodeId }, + })), + })), + + clearWorkflow: () => set({ nodes: [], edges: [], selectedEdgeId: null, triggerNodeId: null }), }); export const useWorkflowBuilderStore = create( From f23639dfa348cb554eac50f28002b32dbb13b863 Mon Sep 17 00:00:00 2001 From: Ronen Mars Date: Mon, 29 Dec 2025 09:14:14 +0200 Subject: [PATCH 3/5] feat: dnd --- .../configuration/configrationDrawer.tsx | 113 ++++++- .../workflowBuilder/connectionEditorModal.tsx | 9 +- .../workflowBuilder/edges/dataEdge.tsx | 227 ++++++++++++++ .../workflowBuilder/edges/executionEdge.tsx | 128 ++++++++ .../organisms/workflowBuilder/edges/index.ts | 2 + .../organisms/workflowBuilder/index.ts | 7 + .../modals/connectionConfigModal.tsx | 216 +++++++++++++ .../organisms/workflowBuilder/modals/index.ts | 2 + .../modals/triggerConfigModal.tsx | 285 ++++++++++++++++++ .../workflowBuilder/nodes/codeNode.tsx | 191 ++++++++++++ .../workflowBuilder/nodes/connectionNode.tsx | 210 +++++++++++++ .../organisms/workflowBuilder/nodes/index.ts | 3 + .../workflowBuilder/nodes/triggerNode.tsx | 149 +++++++++ .../sidebar/codeFilesSection.tsx | 190 ++++++++++++ .../sidebar/connectionsSection.tsx | 191 ++++++++++++ .../workflowBuilder/sidebar/index.ts | 5 + .../sidebar/triggerSection.tsx | 115 +++++++ .../sidebar/variablesSection.tsx | 268 ++++++++++++++++ .../sidebar/workflowSidebar.tsx | 46 +++ .../workflowBuilder/workflowBuilder.tsx | 47 ++- .../workflowBuilder/workflowCanvas.tsx | 179 ++++++++--- src/enums/components/modal.enum.ts | 3 + src/interfaces/components/index.ts | 3 +- .../components/workflowBuilder.interface.ts | 105 ++++++- .../en/workflowBuilder/translation.json | 140 ++++++++- src/store/useWorkflowBuilderStore.ts | 181 +++++------ 26 files changed, 2847 insertions(+), 168 deletions(-) create mode 100644 src/components/organisms/workflowBuilder/edges/dataEdge.tsx create mode 100644 src/components/organisms/workflowBuilder/edges/executionEdge.tsx create mode 100644 src/components/organisms/workflowBuilder/edges/index.ts create mode 100644 src/components/organisms/workflowBuilder/modals/connectionConfigModal.tsx create mode 100644 src/components/organisms/workflowBuilder/modals/index.ts create mode 100644 src/components/organisms/workflowBuilder/modals/triggerConfigModal.tsx create mode 100644 src/components/organisms/workflowBuilder/nodes/codeNode.tsx create mode 100644 src/components/organisms/workflowBuilder/nodes/connectionNode.tsx create mode 100644 src/components/organisms/workflowBuilder/nodes/index.ts create mode 100644 src/components/organisms/workflowBuilder/nodes/triggerNode.tsx create mode 100644 src/components/organisms/workflowBuilder/sidebar/codeFilesSection.tsx create mode 100644 src/components/organisms/workflowBuilder/sidebar/connectionsSection.tsx create mode 100644 src/components/organisms/workflowBuilder/sidebar/index.ts create mode 100644 src/components/organisms/workflowBuilder/sidebar/triggerSection.tsx create mode 100644 src/components/organisms/workflowBuilder/sidebar/variablesSection.tsx create mode 100644 src/components/organisms/workflowBuilder/sidebar/workflowSidebar.tsx 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} { useEffect(() => { if (edgeId) { const edge = edges.find((e) => e.id === edgeId); - const existingCode = edge?.data?.code || defaultCode; - const existingEventType = edge?.data?.eventType; - const existingVars = edge?.data?.variables || []; + 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) { diff --git a/src/components/organisms/workflowBuilder/edges/dataEdge.tsx b/src/components/organisms/workflowBuilder/edges/dataEdge.tsx new file mode 100644 index 0000000000..d8dfa2d4e8 --- /dev/null +++ b/src/components/organisms/workflowBuilder/edges/dataEdge.tsx @@ -0,0 +1,227 @@ +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)} + 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..d550b997b5 --- /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 + ? "bg-amber-900/40 border-amber-500/50 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 index cc0df64afc..3c5dd8ad73 100644 --- a/src/components/organisms/workflowBuilder/index.ts +++ b/src/components/organisms/workflowBuilder/index.ts @@ -1,5 +1,12 @@ +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 { WorkflowCanvas } from "./workflowCanvas"; diff --git a/src/components/organisms/workflowBuilder/modals/connectionConfigModal.tsx b/src/components/organisms/workflowBuilder/modals/connectionConfigModal.tsx new file mode 100644 index 0000000000..387de7e21a --- /dev/null +++ b/src/components/organisms/workflowBuilder/modals/connectionConfigModal.tsx @@ -0,0 +1,216 @@ +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..e8a027062d --- /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..8698c6bdb8 --- /dev/null +++ b/src/components/organisms/workflowBuilder/nodes/codeNode.tsx @@ -0,0 +1,191 @@ +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); + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + + + + +
+
+
+ +
+
+ + {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..0343f62d19 --- /dev/null +++ b/src/components/organisms/workflowBuilder/nodes/connectionNode.tsx @@ -0,0 +1,210 @@ +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] + ); + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + + + + +
+ {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..5929712f52 --- /dev/null +++ b/src/components/organisms/workflowBuilder/nodes/triggerNode.tsx @@ -0,0 +1,149 @@ +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]); + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + + + + + + +
+
+ +
+ + + {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..0e32dd833a --- /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..4f9c48520e --- /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 index 50744ffaa6..b3d4f34670 100644 --- a/src/components/organisms/workflowBuilder/workflowBuilder.tsx +++ b/src/components/organisms/workflowBuilder/workflowBuilder.tsx @@ -6,7 +6,8 @@ import { useTranslation } from "react-i18next"; import { ConnectionEditorModal } from "./connectionEditorModal"; import { DeleteEdgeModal } from "./deleteEdgeModal"; import { DeleteNodeModal } from "./deleteNodeModal"; -import { IntegrationsSidebar } from "./integrationsSidebar"; +import { ConnectionConfigModal, TriggerConfigModal } from "./modals"; +import { WorkflowSidebar } from "./sidebar"; import { WorkflowCanvas } from "./workflowCanvas"; import { useWorkflowBuilderStore } from "@store/useWorkflowBuilderStore"; @@ -14,7 +15,12 @@ import { Button, Typography } from "@components/atoms"; export const WorkflowBuilder = () => { const { t } = useTranslation("workflowBuilder"); - const { clearWorkflow, nodes, edges } = useWorkflowBuilderStore(); + const { clearWorkflow, nodes, edges, variables, getTriggerNodes, getCodeNodes, getConnectionNodes } = + useWorkflowBuilderStore(); + + const triggerCount = getTriggerNodes().length; + const codeCount = getCodeNodes().length; + const connectionCount = getConnectionNodes().length; return ( @@ -28,8 +34,37 @@ export const WorkflowBuilder = () => { {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} +
+
+ {t("stats", { nodes: nodes.length, edges: edges.length })} {nodes.length > 0 || edges.length > 0 ? ( @@ -40,7 +75,7 @@ export const WorkflowBuilder = () => {
- +
@@ -49,6 +84,8 @@ export const WorkflowBuilder = () => { + + ); }; diff --git a/src/components/organisms/workflowBuilder/workflowCanvas.tsx b/src/components/organisms/workflowBuilder/workflowCanvas.tsx index e37c17826b..bd8684086a 100644 --- a/src/components/organisms/workflowBuilder/workflowCanvas.tsx +++ b/src/components/organisms/workflowBuilder/workflowCanvas.tsx @@ -9,10 +9,10 @@ import { useEdgesState, addEdge, NodeTypes, + EdgeTypes, OnConnect, OnNodesChange, OnEdgesChange, - EdgeMouseHandler, Node, Edge, MarkerType, @@ -21,19 +21,35 @@ import { import "@xyflow/react/dist/style.css"; import { CodeEdge } from "./codeEdge"; +import { DataEdge, ExecutionEdge } from "./edges"; import { IntegrationNode } from "./integrationNode"; -import { IntegrationNodeData, WorkflowEdge, WorkflowNode } from "@interfaces/components/workflowBuilder.interface"; +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 { fitleredIntegrationsMap, Integrations } from "@src/enums/components"; +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 = { +const edgeTypes: EdgeTypes = { code: CodeEdge, + execution: ExecutionEdge, + data: DataEdge, }; export const WorkflowCanvas = () => { @@ -74,39 +90,63 @@ export const WorkflowCanvas = () => { const onConnect: OnConnect = useCallback( (connection: Connection) => { const sourceNode = storeNodes.find((n) => n.id === connection.source); - const sourceIntegration = sourceNode?.data?.integration; + 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: WorkflowEdge = { + const newEdge = { id: `edge-${connection.source}-${connection.target}-${Date.now()}`, source: connection.source!, target: connection.target!, sourceHandle: connection.sourceHandle, targetHandle: connection.targetHandle, - type: "code", - animated: false, + type: edgeType, + animated: edgeType === "execution", markerEnd: { type: MarkerType.ArrowClosed, - color: "#6b7280", + color: edgeType === "execution" ? "#f59e0b" : "#22c55e", }, - data: { code: "", eventType: "", variables: [], status: "draft" }, - }; + data: edgeData, + } as WorkflowEdge; const updatedEdges = addEdge(newEdge as Edge, storeEdges as Edge[]) as WorkflowEdge[]; setEdges(updatedEdges); - setSelectedEdgeId(newEdge.id); - openModal(ModalName.connectionCodeEditor, { edgeId: newEdge.id, sourceIntegration }); - }, - [storeNodes, storeEdges, setEdges, setSelectedEdgeId, openModal] - ); - const onEdgeClick: EdgeMouseHandler = useCallback( - (_, edge) => { - const sourceNode = storeNodes.find((n) => n.id === edge.source); - const sourceIntegration = sourceNode?.data?.integration; - setSelectedEdgeId(edge.id); - openModal(ModalName.connectionCodeEditor, { edgeId: edge.id, sourceIntegration }); + if (edgeType === "code") { + setSelectedEdgeId(newEdge.id); + openModal(ModalName.connectionCodeEditor, { edgeId: newEdge.id }); + } }, - [storeNodes, setSelectedEdgeId, openModal] + [storeNodes, storeEdges, setEdges, setSelectedEdgeId, openModal] ); const onDragOver = useCallback((event: DragEvent) => { @@ -118,33 +158,87 @@ export const WorkflowCanvas = () => { (event: DragEvent) => { event.preventDefault(); - const data = event.dataTransfer.getData("application/reactflow"); - if (!data) return; - - const { value: integrationValue, label } = JSON.parse(data) as { label: string; value: string }; - const integration = fitleredIntegrationsMap[integrationValue as Integrations]; - if (!integration) return; + const workflowData = event.dataTransfer.getData("application/workflow-node"); + const reactFlowData = event.dataTransfer.getData("application/reactflow"); const position = screenToFlowPosition({ x: event.clientX, y: event.clientY, }); - const nodeData: IntegrationNodeData = { - integration: integrationValue as Integrations, - label, - }; + 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: nodeData, - }; + const newNode: WorkflowNode = { + id: `node-${integrationValue}-${Date.now()}`, + type: "integration", + position, + data: { + integration: integrationValue as Integrations, + label, + }, + }; - setNodes([...storeNodes, newNode]); + setNodes([...storeNodes, newNode]); + } }, - [storeNodes, setNodes, screenToFlowPosition] + [storeNodes, setNodes, screenToFlowPosition, openModal] ); const memoizedNodes = useMemo(() => storeNodes as Node[], [storeNodes]); @@ -153,7 +247,7 @@ export const WorkflowCanvas = () => { return (
{ onConnect={onConnect} onDragOver={onDragOver} onDrop={onDrop} - onEdgeClick={onEdgeClick} onEdgesChange={handleEdgesChange} onNodesChange={handleNodesChange} > diff --git a/src/enums/components/modal.enum.ts b/src/enums/components/modal.enum.ts index a0ad13b8c5..12f028f5c9 100644 --- a/src/enums/components/modal.enum.ts +++ b/src/enums/components/modal.enum.ts @@ -45,4 +45,7 @@ export enum ModalName { 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 dde7d42224..ebf5baffbf 100644 --- a/src/interfaces/components/index.ts +++ b/src/interfaces/components/index.ts @@ -167,7 +167,8 @@ export type { ProjectsTableMeta } from "./projectsTable.interface"; export type { IntegrationNodeData, WorkflowNode, - WorkflowEdgeData, WorkflowEdge, WorkflowBuilderState, + LegacyWorkflowEdgeData, + WorkflowEdgeVariable, } from "./workflowBuilder.interface"; diff --git a/src/interfaces/components/workflowBuilder.interface.ts b/src/interfaces/components/workflowBuilder.interface.ts index 14861a5c1e..79edec249d 100644 --- a/src/interfaces/components/workflowBuilder.interface.ts +++ b/src/interfaces/components/workflowBuilder.interface.ts @@ -2,13 +2,87 @@ 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 WorkflowNode = Node; +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; @@ -18,32 +92,53 @@ export interface WorkflowEdgeVariable { export type EdgeStatus = "draft" | "configured" | "active" | "error"; -export interface WorkflowEdgeData extends Record { +export interface LegacyWorkflowEdgeData extends Record { code: string; eventType: string; variables: WorkflowEdgeVariable[]; status: EdgeStatus; } -export type WorkflowEdge = Edge; +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 WorkflowBuilderState { nodes: WorkflowNode[]; edges: WorkflowEdge[]; + variables: ProjectVariable[]; + selectedNodeId: string | null; selectedEdgeId: string | null; triggerNodeId: string | null; + 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; - updateEdgeStatus: (edgeId: string, status: EdgeStatus) => void; setEdges: (edges: WorkflowEdge[]) => void; setSelectedEdgeId: (edgeId: string | null) => void; - setTriggerNodeId: (nodeId: 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[]; } diff --git a/src/locales/en/workflowBuilder/translation.json b/src/locales/en/workflowBuilder/translation.json index c9f40d39ae..df110df50f 100644 --- a/src/locales/en/workflowBuilder/translation.json +++ b/src/locales/en/workflowBuilder/translation.json @@ -1,11 +1,82 @@ { "title": "Workflow Builder", - "description": "Drag integrations from the sidebar and connect them to build your workflow", + "description": "Build your automation workflow by connecting triggers, code, and integrations", "clearCanvas": "Clear Canvas", - "stats": "{{nodes}} nodes, {{edges}} connections", + "stats": "{{nodes}} nodes, {{edges}} edges", "sidebar": { - "title": "Integrations", - "description": "Drag to add to canvas" + "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", @@ -17,6 +88,63 @@ "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?", @@ -25,9 +153,9 @@ "delete": "Delete" }, "deleteNodeModal": { - "title": "Delete Integration", + "title": "Delete Node", "content": "Are you sure you want to delete \"{{name}}\"?", - "connectionWarning": "This integration has {{count}} connection(s) that will also be deleted.", + "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/store/useWorkflowBuilderStore.ts b/src/store/useWorkflowBuilderStore.ts index dca25eeb3e..9906b39074 100644 --- a/src/store/useWorkflowBuilderStore.ts +++ b/src/store/useWorkflowBuilderStore.ts @@ -4,23 +4,22 @@ import { shallow } from "zustand/shallow"; import { createWithEqualityFn as create } from "zustand/traditional"; import { - EdgeStatus, + CodeNode, + ConnectionNode, + LegacyWorkflowEdgeData, + ProjectVariable, + TriggerNode, WorkflowBuilderState, WorkflowEdge, WorkflowEdgeVariable, WorkflowNode, } from "@interfaces/components/workflowBuilder.interface"; -const computeEdgeStatus = (code: string, eventType: string): EdgeStatus => { - const hasCode = code && code.trim().length > 0; - const hasEventType = eventType && eventType.trim().length > 0; - if (hasCode && hasEventType) return "configured"; - return "draft"; -}; - -const store: StateCreator = (set) => ({ +const store: StateCreator = (set, get) => ({ nodes: [], edges: [], + variables: [], + selectedNodeId: null, selectedEdgeId: null, triggerNodeId: null, @@ -30,14 +29,18 @@ const store: StateCreator = (set) => ({ })), removeNode: (nodeId: string) => - set((state) => { - const newTriggerNodeId = state.triggerNodeId === nodeId ? null : state.triggerNodeId; - return { - nodes: state.nodes.filter((node) => node.id !== nodeId), - edges: state.edges.filter((edge) => edge.source !== nodeId && edge.target !== nodeId), - triggerNodeId: newTriggerNodeId, - }; - }), + 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) => ({ @@ -46,98 +49,46 @@ const store: StateCreator = (set) => ({ setNodes: (nodes: WorkflowNode[]) => set({ nodes }), + setSelectedNodeId: (nodeId: string | null) => set({ selectedNodeId: nodeId }), + + setTriggerNodeId: (nodeId: string | null) => set({ triggerNodeId: nodeId }), + addEdge: (edge: WorkflowEdge) => - set((state) => { - const isFirstEdgeFromSource = !state.edges.some((e) => e.source === edge.source); - const shouldSetTrigger = isFirstEdgeFromSource && !state.triggerNodeId; - return { - edges: [...state.edges, edge], - triggerNodeId: shouldSetTrigger ? edge.source : state.triggerNodeId, - }; - }), + set((state) => ({ + edges: [...state.edges, edge], + })), removeEdge: (edgeId: string) => - set((state) => { - const edgeToRemove = state.edges.find((e) => e.id === edgeId); - const remainingEdges = state.edges.filter((edge) => edge.id !== edgeId); - const sourceStillHasEdges = remainingEdges.some((e) => e.source === edgeToRemove?.source); - const newTriggerNodeId = - state.triggerNodeId === edgeToRemove?.source && !sourceStillHasEdges ? null : state.triggerNodeId; - return { - edges: remainingEdges, - selectedEdgeId: state.selectedEdgeId === edgeId ? null : state.selectedEdgeId, - triggerNodeId: newTriggerNodeId, - }; - }), + set((state) => ({ + edges: state.edges.filter((edge) => edge.id !== edgeId), + selectedEdgeId: state.selectedEdgeId === edgeId ? null : state.selectedEdgeId, + })), - updateEdgeCode: (edgeId: string, code: string) => + updateEdge: (edgeId: string, data: Partial) => set((state) => ({ - edges: state.edges.map((edge) => { - if (edge.id !== edgeId) return edge; - const eventType = edge.data?.eventType || ""; - const variables = edge.data?.variables || []; - return { - ...edge, - data: { - code, - eventType, - variables, - status: computeEdgeStatus(code, eventType), - }, - }; - }), + edges: state.edges.map((edge) => + edge.id === edgeId ? ({ ...edge, data: { ...edge.data, ...data } } as WorkflowEdge) : edge + ), })), - updateEdgeEventType: (edgeId: string, eventType: string) => + updateEdgeCode: (edgeId: string, code: string) => set((state) => ({ - edges: state.edges.map((edge) => { - if (edge.id !== edgeId) return edge; - const code = edge.data?.code || ""; - const variables = edge.data?.variables || []; - return { - ...edge, - data: { - code, - eventType, - variables, - status: computeEdgeStatus(code, eventType), - }, - }; - }), + edges: state.edges.map((edge) => + edge.id === edgeId ? { ...edge, data: { ...(edge.data as LegacyWorkflowEdgeData), code } } : edge + ), })), - updateEdgeVariables: (edgeId: string, variables: WorkflowEdgeVariable[]) => + updateEdgeEventType: (edgeId: string, eventType: string) => set((state) => ({ - edges: state.edges.map((edge) => { - if (edge.id !== edgeId) return edge; - return { - ...edge, - data: { - ...edge.data, - code: edge.data?.code || "", - eventType: edge.data?.eventType || "", - status: edge.data?.status || "draft", - variables, - }, - }; - }), + edges: state.edges.map((edge) => + edge.id === edgeId ? { ...edge, data: { ...(edge.data as LegacyWorkflowEdgeData), eventType } } : edge + ), })), - updateEdgeStatus: (edgeId: string, status: EdgeStatus) => + updateEdgeVariables: (edgeId: string, variables: WorkflowEdgeVariable[]) => set((state) => ({ edges: state.edges.map((edge) => - edge.id === edgeId - ? { - ...edge, - data: { - ...edge.data, - code: edge.data?.code || "", - eventType: edge.data?.eventType || "", - variables: edge.data?.variables || [], - status, - }, - } - : edge + edge.id === edgeId ? { ...edge, data: { ...(edge.data as LegacyWorkflowEdgeData), variables } } : edge ), })), @@ -145,22 +96,48 @@ const store: StateCreator = (set) => ({ setSelectedEdgeId: (edgeId: string | null) => set({ selectedEdgeId: edgeId }), - setTriggerNodeId: (nodeId: string | null) => + addVariable: (variable: ProjectVariable) => + set((state) => ({ + variables: [...state.variables, variable], + })), + + updateVariable: (id: string, updates: Partial) => set((state) => ({ - triggerNodeId: nodeId, - nodes: state.nodes.map((node) => ({ - ...node, - data: { ...node.data, isTrigger: node.id === nodeId }, - })), + variables: state.variables.map((v) => (v.id === id ? { ...v, ...updates } : v)), })), - clearWorkflow: () => set({ nodes: [], edges: [], selectedEdgeId: null, triggerNodeId: null }), + removeVariable: (id: string) => + set((state) => ({ + variables: state.variables.filter((v) => v.id !== id), + })), + + setVariables: (variables: ProjectVariable[]) => set({ variables }), + + clearWorkflow: () => + set({ + nodes: [], + edges: [], + variables: [], + selectedNodeId: null, + selectedEdgeId: null, + triggerNodeId: null, + }), + + 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"), }); export const useWorkflowBuilderStore = create( persist(store, { name: "workflow-builder-storage", - partialize: (state) => ({ nodes: state.nodes, edges: state.edges }), + partialize: (state) => ({ + nodes: state.nodes, + edges: state.edges, + variables: state.variables, + }), }), shallow ); From a795baf04a39ee3ae429efed5b99dfa0456d244e Mon Sep 17 00:00:00 2001 From: Ronen Mars Date: Mon, 29 Dec 2025 09:23:33 +0200 Subject: [PATCH 4/5] feat: dnd --- .../organisms/workflowBuilder/edges/dataEdge.tsx | 14 ++++++++++---- .../workflowBuilder/edges/executionEdge.tsx | 2 +- .../modals/connectionConfigModal.tsx | 2 +- .../workflowBuilder/modals/triggerConfigModal.tsx | 2 +- .../organisms/workflowBuilder/nodes/codeNode.tsx | 15 ++++++++++++++- .../workflowBuilder/nodes/connectionNode.tsx | 13 +++++++++++++ .../workflowBuilder/nodes/triggerNode.tsx | 13 +++++++++++++ .../workflowBuilder/sidebar/codeFilesSection.tsx | 4 ++-- .../sidebar/connectionsSection.tsx | 2 +- src/locales/en/tabs/translation.json | 2 +- tailwind.config.cjs | 15 +++++++++++++++ 11 files changed, 72 insertions(+), 12 deletions(-) diff --git a/src/components/organisms/workflowBuilder/edges/dataEdge.tsx b/src/components/organisms/workflowBuilder/edges/dataEdge.tsx index d8dfa2d4e8..e70130a014 100644 --- a/src/components/organisms/workflowBuilder/edges/dataEdge.tsx +++ b/src/components/organisms/workflowBuilder/edges/dataEdge.tsx @@ -148,6 +148,12 @@ export const DataEdge = ({
setShowDetails(!showDetails)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setShowDetails(!showDetails); + } + }} role="button" tabIndex={0} > @@ -170,13 +176,13 @@ export const DataEdge = ({ {readOps.length > 0 ? (
- {readOps.length} + {readOps.length}
) : null} {writeOps.length > 0 ? (
- - {writeOps.length} + + {writeOps.length}
) : null}
@@ -203,7 +209,7 @@ export const DataEdge = ({ ) : null} {writeOps.length > 0 ? (
-
+
Write Operations
diff --git a/src/components/organisms/workflowBuilder/edges/executionEdge.tsx b/src/components/organisms/workflowBuilder/edges/executionEdge.tsx index d550b997b5..a8a4a3d935 100644 --- a/src/components/organisms/workflowBuilder/edges/executionEdge.tsx +++ b/src/components/organisms/workflowBuilder/edges/executionEdge.tsx @@ -63,7 +63,7 @@ export const ExecutionEdge = ({ cn( "flex items-center gap-1.5 rounded-full border px-3 py-1.5 transition-all duration-200", isActive - ? "bg-amber-900/40 border-amber-500/50 shadow-lg shadow-amber-500/20" + ? "border-amber-500/50 bg-amber-900/40 shadow-lg shadow-amber-500/20" : "border-gray-600 bg-gray-800", isHovered && "scale-105" ), diff --git a/src/components/organisms/workflowBuilder/modals/connectionConfigModal.tsx b/src/components/organisms/workflowBuilder/modals/connectionConfigModal.tsx index 387de7e21a..e366c8703e 100644 --- a/src/components/organisms/workflowBuilder/modals/connectionConfigModal.tsx +++ b/src/components/organisms/workflowBuilder/modals/connectionConfigModal.tsx @@ -163,7 +163,7 @@ export const ConnectionConfigModal = () => {
{status === "disconnected" ? ( -
+
Authentication Required diff --git a/src/components/organisms/workflowBuilder/modals/triggerConfigModal.tsx b/src/components/organisms/workflowBuilder/modals/triggerConfigModal.tsx index e8a027062d..d40e0a6bbe 100644 --- a/src/components/organisms/workflowBuilder/modals/triggerConfigModal.tsx +++ b/src/components/organisms/workflowBuilder/modals/triggerConfigModal.tsx @@ -135,7 +135,7 @@ export const TriggerConfigModal = () => { className={cn( "flex flex-1 items-center justify-center gap-2 rounded-lg border-2 py-3 transition-all", triggerType === t.type - ? "bg-blue-900/20 border-blue-500" + ? "border-blue-500 bg-blue-900/20" : "border-gray-700 bg-gray-900 hover:border-gray-600" )} key={t.type} diff --git a/src/components/organisms/workflowBuilder/nodes/codeNode.tsx b/src/components/organisms/workflowBuilder/nodes/codeNode.tsx index 8698c6bdb8..03ff68bdcb 100644 --- a/src/components/organisms/workflowBuilder/nodes/codeNode.tsx +++ b/src/components/organisms/workflowBuilder/nodes/codeNode.tsx @@ -77,12 +77,25 @@ const CodeNodeComponent = ({ id, data, selected }: CodeNodeProps) => { 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} >
{activeEntryPoints.length > 0 ? ( - + {activeEntryPoints.length} active ) : null} @@ -158,7 +158,7 @@ export const CodeFilesSection = () => { Code Files {allFiles.length > 0 ? ( - + {allFiles.length} ) : null} diff --git a/src/components/organisms/workflowBuilder/sidebar/connectionsSection.tsx b/src/components/organisms/workflowBuilder/sidebar/connectionsSection.tsx index 4f9c48520e..8fdbe5b751 100644 --- a/src/components/organisms/workflowBuilder/sidebar/connectionsSection.tsx +++ b/src/components/organisms/workflowBuilder/sidebar/connectionsSection.tsx @@ -118,7 +118,7 @@ export const ConnectionsSection = () => { Connections {projectConnections.length > 0 ? ( - + {projectConnections.length} ) : null} 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/tailwind.config.cjs b/tailwind.config.cjs index 3f1211093a..06f8fb2170 100644 --- a/tailwind.config.cjs +++ b/tailwind.config.cjs @@ -153,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", From c59954d566c67fa5ac02232b7e7d553ded4e68b9 Mon Sep 17 00:00:00 2001 From: Ronen Mars Date: Mon, 29 Dec 2025 12:49:49 +0200 Subject: [PATCH 5/5] feat: dnd --- src/components/molecules/modal.tsx | 26 +- .../workflowBuilder/connectionEditorModal.tsx | 3 +- .../workflowBuilder/deleteEdgeModal.tsx | 10 +- .../workflowBuilder/deleteNodeModal.tsx | 24 +- .../organisms/workflowBuilder/index.ts | 3 + .../modals/connectionConfigModal.tsx | 17 +- .../modals/triggerConfigModal.tsx | 2 +- .../workflowBuilder/workflowBuilder.tsx | 63 +- .../workflowBuilder/workflowBuilderError.tsx | 51 ++ .../workflowBuilderSkeleton.tsx | 58 ++ .../workflowBuilderWarnings.tsx | 82 +++ src/constants/namespaces.logger.constants.ts | 1 + src/interfaces/components/modal.interface.ts | 2 + .../components/workflowBuilder.interface.ts | 18 + .../en/workflowBuilder/translation.json | 1 + src/services/index.ts | 6 + src/services/workflowBuilder.service.ts | 220 +++++++ src/store/useWorkflowBuilderStore.ts | 96 ++- src/utilities/codeParser.ts | 199 ++++++ src/utilities/connectionAnalyzer.ts | 309 +++++++++ src/utilities/index.ts | 56 ++ src/utilities/workflowAutoLayout.ts | 424 +++++++++++++ src/utilities/workflowGraphBuilder.ts | 590 ++++++++++++++++++ 23 files changed, 2232 insertions(+), 29 deletions(-) create mode 100644 src/components/organisms/workflowBuilder/workflowBuilderError.tsx create mode 100644 src/components/organisms/workflowBuilder/workflowBuilderSkeleton.tsx create mode 100644 src/components/organisms/workflowBuilder/workflowBuilderWarnings.tsx create mode 100644 src/services/workflowBuilder.service.ts create mode 100644 src/utilities/codeParser.ts create mode 100644 src/utilities/connectionAnalyzer.ts create mode 100644 src/utilities/workflowAutoLayout.ts create mode 100644 src/utilities/workflowGraphBuilder.ts 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/workflowBuilder/connectionEditorModal.tsx b/src/components/organisms/workflowBuilder/connectionEditorModal.tsx index 97e09de29b..97e9edb20b 100644 --- a/src/components/organisms/workflowBuilder/connectionEditorModal.tsx +++ b/src/components/organisms/workflowBuilder/connectionEditorModal.tsx @@ -153,8 +153,9 @@ export const ConnectionEditorModal = () => { return (
diff --git a/src/components/organisms/workflowBuilder/deleteEdgeModal.tsx b/src/components/organisms/workflowBuilder/deleteEdgeModal.tsx index 2deae189ef..0e087a1df6 100644 --- a/src/components/organisms/workflowBuilder/deleteEdgeModal.tsx +++ b/src/components/organisms/workflowBuilder/deleteEdgeModal.tsx @@ -29,17 +29,17 @@ export const DeleteEdgeModal = () => { }, [closeModal]); return ( - +
-

{t("title")}

-

{t("content")}

+

{t("title")}

+

{t("content")}

{t("warning")}

-
diff --git a/src/components/organisms/workflowBuilder/index.ts b/src/components/organisms/workflowBuilder/index.ts index 3c5dd8ad73..fec92c6bee 100644 --- a/src/components/organisms/workflowBuilder/index.ts +++ b/src/components/organisms/workflowBuilder/index.ts @@ -9,4 +9,7 @@ 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/modals/connectionConfigModal.tsx b/src/components/organisms/workflowBuilder/modals/connectionConfigModal.tsx index e366c8703e..633095cb02 100644 --- a/src/components/organisms/workflowBuilder/modals/connectionConfigModal.tsx +++ b/src/components/organisms/workflowBuilder/modals/connectionConfigModal.tsx @@ -80,7 +80,7 @@ export const ConnectionConfigModal = () => { const StatusIcon = statusInfo.icon; return ( - +
{integration?.icon ? ( @@ -144,7 +144,7 @@ export const ConnectionConfigModal = () => { ) : null}
@@ -203,7 +207,12 @@ export const ConnectionConfigModal = () => {
-
-
+
-
+
+
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/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/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 index 79edec249d..e125206005 100644 --- a/src/interfaces/components/workflowBuilder.interface.ts +++ b/src/interfaces/components/workflowBuilder.interface.ts @@ -106,6 +106,12 @@ 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[]; @@ -114,6 +120,13 @@ export interface WorkflowBuilderState { 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; @@ -141,4 +154,9 @@ export interface WorkflowBuilderState { 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/workflowBuilder/translation.json b/src/locales/en/workflowBuilder/translation.json index df110df50f..c8372f1738 100644 --- a/src/locales/en/workflowBuilder/translation.json +++ b/src/locales/en/workflowBuilder/translation.json @@ -3,6 +3,7 @@ "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", 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/useWorkflowBuilderStore.ts b/src/store/useWorkflowBuilderStore.ts index 9906b39074..3d7f49cf03 100644 --- a/src/store/useWorkflowBuilderStore.ts +++ b/src/store/useWorkflowBuilderStore.ts @@ -6,6 +6,7 @@ import { createWithEqualityFn as create } from "zustand/traditional"; import { CodeNode, ConnectionNode, + GraphBuildWarning, LegacyWorkflowEdgeData, ProjectVariable, TriggerNode, @@ -14,6 +15,16 @@ import { 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: [], @@ -23,6 +34,13 @@ const store: StateCreator = (set, get) => ({ 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], @@ -113,7 +131,8 @@ const store: StateCreator = (set, get) => ({ setVariables: (variables: ProjectVariable[]) => set({ variables }), - clearWorkflow: () => + clearWorkflow: () => { + originalProjectState = null; set({ nodes: [], edges: [], @@ -121,13 +140,83 @@ const store: StateCreator = (set, get) => ({ 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( @@ -137,6 +226,9 @@ export const useWorkflowBuilderStore = create( 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[]; +}