From 684a17f331140d085d1dfaf0074e55daacc12edc Mon Sep 17 00:00:00 2001 From: Tejabudumuru3 Date: Fri, 2 Oct 2026 01:36:40 +0530 Subject: [PATCH] feat: add node configuration types, rich variable input component, node executors, and execution history tracking --- .../src/routes/userRoutes/executionRoutes.ts | 2 +- .../src/routes/userRoutes/userRoutes.ts | 6 +- .../web/app/components/Actions/ActionNode.tsx | 3 +- .../app/components/Actions/ActionSidebar.tsx | 13 +- .../app/components/ExecutionHistoryFooter.tsx | 58 +- apps/web/app/components/nodes/BaseNode.tsx | 125 ++++- apps/web/app/components/nodes/TriggerNode.tsx | 3 +- .../app/components/nodes/TriggerSidebar.tsx | 16 +- apps/web/app/components/ui/NodeIcon.tsx | 79 +++ .../app/components/ui/RichVariableInput.tsx | 64 ++- apps/web/app/components/ui/TestPanel.tsx | 191 ++++++- apps/web/app/components/ui/variable-panel.tsx | 325 ++++++----- apps/web/app/lib/api.ts | 2 +- apps/web/app/lib/nodeConfigs/filter.action.ts | 35 +- apps/web/app/lib/types/node.types.ts | 3 +- .../workflows/[id]/components/ConfigModal.tsx | 411 ++++++++------ apps/web/app/workflows/[id]/page.tsx | 528 ++++++++++-------- apps/web/package.json | 5 +- apps/web/store/slices/nodeOutputSlice.ts | 1 + apps/web/store/slices/workflowSlice.ts | 56 +- apps/worker/src/engine/executor.ts | 95 +++- packages/common/src/index.ts | 4 +- packages/common/src/interpolation.ts | 95 +++- packages/nodes/src/filter/filter.executor.ts | 92 ++- packages/nodes/src/filter/filter.node.ts | 33 +- packages/nodes/src/gmail/gmail.executor.ts | 18 +- .../google-sheets/google-sheets.executor.ts | 100 +++- packages/nodes/src/index.ts | 2 +- .../src/registry/Execution.config.types.ts | 2 +- pnpm-lock.yaml | 15 + 30 files changed, 1607 insertions(+), 775 deletions(-) create mode 100644 apps/web/app/components/ui/NodeIcon.tsx diff --git a/apps/http-backend/src/routes/userRoutes/executionRoutes.ts b/apps/http-backend/src/routes/userRoutes/executionRoutes.ts index 667e811..8fa40ff 100644 --- a/apps/http-backend/src/routes/userRoutes/executionRoutes.ts +++ b/apps/http-backend/src/routes/userRoutes/executionRoutes.ts @@ -33,7 +33,7 @@ execRouter.post('/node', userMiddleware, async (req: AuthRequest, res: Response) const context = { userId: req.user.sub, - config: config, + config: [config], credentialId: nodeData.CredentialsID || config?.credentialId || "", authType: nodeData.AvailableNode.authType, nodeId: nodeData.id, diff --git a/apps/http-backend/src/routes/userRoutes/userRoutes.ts b/apps/http-backend/src/routes/userRoutes/userRoutes.ts index 4570aa7..f1413b2 100644 --- a/apps/http-backend/src/routes/userRoutes/userRoutes.ts +++ b/apps/http-backend/src/routes/userRoutes/userRoutes.ts @@ -152,7 +152,11 @@ router.get("/getAvailableTriggers", .status(statusCodes.UNAUTHORIZED) .json({ message: "User isnot logged in /not authorized" }); - const Data = await prismaClient.availableTrigger.findMany(); + const Data = await prismaClient.availableTrigger.findMany({ + where: { + type: 'webhook' + } + }); return res.status(statusCodes.OK).json({ message: "Availabe Triggers Fetched Succesfuuly", Data: Data, diff --git a/apps/web/app/components/Actions/ActionNode.tsx b/apps/web/app/components/Actions/ActionNode.tsx index eb41040..e9e82ac 100644 --- a/apps/web/app/components/Actions/ActionNode.tsx +++ b/apps/web/app/components/Actions/ActionNode.tsx @@ -1,4 +1,5 @@ import { Handle, Position } from "@xyflow/react"; +import { NodeIcon } from "@/app/components/ui/NodeIcon"; interface ActionNodeProps { data: { @@ -14,7 +15,7 @@ export const ActionNode = ({ data }: ActionNodeProps) => {
- {data.icon || "⚙️"} + {data.name} {data.type} diff --git a/apps/web/app/components/Actions/ActionSidebar.tsx b/apps/web/app/components/Actions/ActionSidebar.tsx index fc64e83..596ffd6 100644 --- a/apps/web/app/components/Actions/ActionSidebar.tsx +++ b/apps/web/app/components/Actions/ActionSidebar.tsx @@ -12,6 +12,7 @@ import { SelectValue, } from "@workspace/ui/components/select"; import { useActions } from "@/app/hooks/useActions"; +import { NodeIcon } from "@/app/components/ui/NodeIcon"; interface SideBarProps { isOpen: boolean; @@ -48,7 +49,7 @@ export const ActionSideBar = ({ isOpen, onClose, onSelectAction }: SideBarProps) id: selected.id, name: selected.name, type: selected.type, - icon: 'icon' in selected && selected.icon ? selected.icon : '⚡', + icon: 'icon' in selected && selected.icon ? selected.icon : undefined, }); onClose(); } @@ -60,8 +61,14 @@ export const ActionSideBar = ({ isOpen, onClose, onSelectAction }: SideBarProps) {availableActions.length ? ( availableActions.map((action: any) => ( -
- {'icon' in action && action.icon ? : '⚡'} {action.name} +
+ + {action.name}
)) diff --git a/apps/web/app/components/ExecutionHistoryFooter.tsx b/apps/web/app/components/ExecutionHistoryFooter.tsx index b59a21e..46e2bb6 100644 --- a/apps/web/app/components/ExecutionHistoryFooter.tsx +++ b/apps/web/app/components/ExecutionHistoryFooter.tsx @@ -206,6 +206,34 @@ export default function ExecutionHistoryFooter({ // Detect sidebar state via CSS custom property / DOM attribute const [sidebarWidth, setSidebarWidth] = useState(256); // 16rem default + // Drawer height state for vertical resizing + const [drawerHeight, setDrawerHeight] = useState(450); + const isDraggingRef = useRef(false); + + const handleResizeMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + isDraggingRef.current = true; + const startY = e.clientY; + const startH = drawerHeight; + + const onMouseMove = (moveEvent: MouseEvent) => { + if (!isDraggingRef.current) return; + const deltaY = startY - moveEvent.clientY; // moving cursor up increases drawer height + const maxHeight = typeof window !== 'undefined' ? window.innerHeight - 80 : 800; + const newH = Math.min(Math.max(startH + deltaY, 200), maxHeight); + setDrawerHeight(newH); + }; + + const onMouseUp = () => { + isDraggingRef.current = false; + window.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('mouseup', onMouseUp); + }; + + window.addEventListener('mousemove', onMouseMove); + window.addEventListener('mouseup', onMouseUp); + }; + useEffect(() => { const observeSidebar = () => { const wrapper = document.querySelector('[data-slot="sidebar-wrapper"]'); @@ -399,7 +427,7 @@ export default function ExecutionHistoryFooter({ bottom: '48px', left: sidebarWidth, right: 0, - height: '55vh', + height: `${drawerHeight}px`, background: 'linear-gradient(180deg, #0a0d0a 0%, #0c100c 100%)', borderTop: '1px solid #2a3525', zIndex: 8, @@ -409,6 +437,34 @@ export default function ExecutionHistoryFooter({ backdropFilter: 'blur(16px)', }} > + {/* Top Resizable Drag Handle */} +
+
+
{/* Panel Header */}
void; onTest?: () => void; - onAddChild?: () => void; + onAddChild?: (sourceHandleId?: string) => void; }; } @@ -93,12 +95,13 @@ export default function BaseNode({ id, type, data }: BaseNodeProps) {
{/* Icon + Label */}
- - {icon ? - : ("⚡")} - - + {label}
@@ -134,28 +137,104 @@ export default function BaseNode({ id, type, data }: BaseNodeProps) {
- {/* Add child button */} - {onAddChild && ( -
- -
- )} - {/* Handles */} {nodeType === "action" ? ( <> - {/* Action nodes get both handles */} - - + {/* Action nodes get input handle on left */} + + + {(() => { + const nodeConfigDef = getNodeConfig(label); + let resolvedOutputs = config?.outputs; + + if (!resolvedOutputs && nodeConfigDef) { + const operationField = nodeConfigDef.fields?.find((f: any) => f.name === "operation"); + if (operationField && operationField.options) { + const selectedOpId = config?.operation || operationField.defaultValue; + const selectedOp = operationField.options.find((o: any) => o.id === selectedOpId); + if (selectedOp && selectedOp.outputs) { + resolvedOutputs = selectedOp.outputs; + } + } + } + + const outputs = resolvedOutputs || [{ id: "out-0", label: "Output" }]; + + return outputs.map((output: any, index: number) => { + const topPosition = `${((index + 1) * 100) / (outputs.length + 1)}%`; + const bgClass = output.id === "out-1" ? "!bg-rose-500" : "!bg-indigo-500"; + const textClass = output.id === "out-1" ? "text-rose-600 bg-rose-50 border-rose-200" : "text-indigo-600 bg-indigo-50 border-indigo-200"; + + return ( +
+ + {/* The + Button inside the Handle! Always visible */} + {onAddChild && ( + + )} + + + {/* Floating Label (Shows on hover or is very subtle next to it) */} +
+ + {output.label} + +
+
+ ); + }); + })()} ) : ( // Trigger node gets only source handle (output) - +
+ + {/* The + Button inside the Handle! Always visible */} + {onAddChild && ( + + )} + +
)}
); diff --git a/apps/web/app/components/nodes/TriggerNode.tsx b/apps/web/app/components/nodes/TriggerNode.tsx index d4cdb86..ee07cae 100644 --- a/apps/web/app/components/nodes/TriggerNode.tsx +++ b/apps/web/app/components/nodes/TriggerNode.tsx @@ -1,4 +1,5 @@ import { Handle, Position } from "@xyflow/react"; +import { NodeIcon } from "@/app/components/ui/NodeIcon"; interface TriggerNodeProps { data: { @@ -12,7 +13,7 @@ interface TriggerNodeProps { export const TriggerNode = ({ data }: TriggerNodeProps) => { return (
-
{data.icon}
+
{data.name}
{data.type}
diff --git a/apps/web/app/components/nodes/TriggerSidebar.tsx b/apps/web/app/components/nodes/TriggerSidebar.tsx index f25feef..fc85115 100644 --- a/apps/web/app/components/nodes/TriggerSidebar.tsx +++ b/apps/web/app/components/nodes/TriggerSidebar.tsx @@ -12,6 +12,7 @@ import { SelectValue, } from "@workspace/ui/components/select"; import { useTriggers } from "@/app/hooks/useTriggers"; +import { NodeIcon } from "@/app/components/ui/NodeIcon"; interface SideBarProps { isOpen: boolean; @@ -42,7 +43,7 @@ export const TriggerSideBar = ({ isOpen, onClose, onSelectTrigger }: SideBarPro id: selected.id, name: selected.name, type: selected.type, - icon: (selected as any).icon ?? "⚡", // fallback or undefined + icon: (selected as any).icon ?? undefined, }); onClose(); } @@ -53,10 +54,15 @@ export const TriggerSideBar = ({ isOpen, onClose, onSelectTrigger }: SideBarPro {triggers.map((trigger) => ( - {/* Display a placeholder icon if 'icon' is missing */} -
- {'icon' in trigger && trigger.icon ? : '⚡'} {trigger.name} -
+
+ + {trigger.name} +
))}
diff --git a/apps/web/app/components/ui/NodeIcon.tsx b/apps/web/app/components/ui/NodeIcon.tsx new file mode 100644 index 0000000..60a2292 --- /dev/null +++ b/apps/web/app/components/ui/NodeIcon.tsx @@ -0,0 +1,79 @@ +import React from "react"; + +interface NodeIconProps { + icon?: string | null; + name?: string; + nodeType?: "trigger" | "action"; + size?: "sm" | "md" | "lg" | "xl"; + className?: string; +} + +const SIZE_MAP = { + sm: "w-7 h-7 text-sm", + md: "w-8 h-8 text-base", + lg: "w-9 h-9 text-lg", + xl: "w-12 h-12 text-2xl", +}; + +export function NodeIcon({ + icon, + name, + nodeType = "action", + size = "md", + className = "", +}: NodeIconProps) { + const isImageUrl = + typeof icon === "string" && + (icon.startsWith("http://") || + icon.startsWith("https://") || + icon.startsWith("/") || + icon.startsWith("data:image")); + + const sizeClasses = SIZE_MAP[size] || SIZE_MAP.md; + + return ( +
+ {isImageUrl ? ( + {name + ) : icon ? ( + {icon} + ) : nodeType === "trigger" ? ( + + + + ) : ( + + + + + )} +
+ ); +} + +export default NodeIcon; diff --git a/apps/web/app/components/ui/RichVariableInput.tsx b/apps/web/app/components/ui/RichVariableInput.tsx index 62646dd..eb90aa0 100644 --- a/apps/web/app/components/ui/RichVariableInput.tsx +++ b/apps/web/app/components/ui/RichVariableInput.tsx @@ -45,7 +45,8 @@ export function parseValueToHtml(rawValue: string, availableNodes: AvailableNode .replace(/"/g, """) .replace(/'/g, "'"); }; - const sanitizedValue = escapeHtml(rawValue); + const sanitizedValue = escapeHtml(rawValue).replace(/\n/g, "
"); + // 2. Parse the sanitized value to inject the visual pills return sanitizedValue.replace(/\{\{([^.]+)\.([^}]+)\}\}/g, (match, nodeId, path) => { const node = availableNodes.find(n => n.id === nodeId); @@ -60,43 +61,56 @@ export function RichVariableInput({ value, onChange, availableNodes, placeholder const editorRef = useRef(null); const isInternalUpdate = useRef(false); - // Initial injection of HTML when the external value changes - useEffect(() => { - if (editorRef.current && !isInternalUpdate.current) { - const newHtml = parseValueToHtml(value, availableNodes); - if (editorRef.current.innerHTML !== newHtml) { - editorRef.current.innerHTML = newHtml; - } - } - isInternalUpdate.current = false; - }, [value, availableNodes]); - - // Handle user input and serialize back to raw string - const handleInput = () => { - if (!editorRef.current) return; - + const getEditorRawString = () => { + if (!editorRef.current) return ""; let rawString = ""; - // Iterate through the DOM children to reconstruct the string - editorRef.current.childNodes.forEach((node) => { + const traverse = (node: Node) => { if (node.nodeType === Node.TEXT_NODE) { - rawString += node.textContent || ""; + // Remove non-breaking spaces inserted by browsers and normalise to normal space + rawString += (node.textContent || "").replace(/\u00A0/g, " "); } else if (node.nodeType === Node.ELEMENT_NODE) { const el = node as HTMLElement; - if (el.classList.contains("pill")) { + if (el.nodeName === "BR") { + rawString += "\n"; + } else if (el.classList?.contains("pill")) { const nodeId = el.getAttribute("data-id"); const path = el.getAttribute("data-path"); if (nodeId && path) { rawString += `{{${nodeId}.${path}}}`; } } else { - // For any pasted elements (br, divs), just extract text - rawString += el.textContent || ""; + if (el.nodeName === "DIV" && rawString.length > 0 && !rawString.endsWith("\n")) { + rawString += "\n"; + } + el.childNodes.forEach(traverse); } } - }); + }; - isInternalUpdate.current = true; + editorRef.current.childNodes.forEach(traverse); + return rawString; + }; + + // Initial injection of HTML when the external value changes + useEffect(() => { + if (editorRef.current) { + const currentRaw = getEditorRawString(); + + // Only update DOM if the incoming value actually differs from what the user typed. + // This stops cursor jumping on re-renders triggered by state updates. + if (currentRaw !== value) { + const newHtml = parseValueToHtml(value, availableNodes); + if (editorRef.current.innerHTML !== newHtml) { + editorRef.current.innerHTML = newHtml; + } + } + } + }, [value, availableNodes]); + + // Handle user input and serialize back to raw string + const handleInput = () => { + const rawString = getEditorRawString(); onChange(rawString); }; @@ -107,7 +121,7 @@ export function RichVariableInput({ value, onChange, availableNodes, placeholder onInput={handleInput} onFocus={onFocus} data-placeholder={placeholder} - className="w-full min-h-[40px] px-3 py-2 rounded-md border border-[#2a3525] bg-[#141a14] text-sm text-[#e8e8d8] focus:outline-none focus:border-[#baf266]/50 empty:before:content-[attr(data-placeholder)] empty:before:text-[#4a5440]" + className="w-full min-h-[40px] px-3 py-2 rounded-md border border-[#2a3525] bg-[#141a14] text-sm text-[#e8e8d8] focus:outline-none focus:border-[#baf266]/50 empty:before:content-[attr(data-placeholder)] empty:before:text-[#4a5440] whitespace-pre-wrap" /> ); } diff --git a/apps/web/app/components/ui/TestPanel.tsx b/apps/web/app/components/ui/TestPanel.tsx index 45bc0b0..20957eb 100644 --- a/apps/web/app/components/ui/TestPanel.tsx +++ b/apps/web/app/components/ui/TestPanel.tsx @@ -1,12 +1,19 @@ "use client"; +import { useState, Fragment } from "react"; interface TestPanelProps { testResult: any; + metadata?: Record; nodeName?: string; nodeIcon?: string; } -export function TestPanel({ testResult, nodeName, nodeIcon }: TestPanelProps) { +export function TestPanel({ testResult, metadata, nodeName, nodeIcon }: TestPanelProps) { + const [expandedCells, setExpandedCells] = useState>({}); + + const toggleExpand = (cellKey: string) => { + setExpandedCells(prev => ({ ...prev, [cellKey]: !prev[cellKey] })); + }; const renderObjectTable = (data: Record) => { const entries = Object.entries(data); @@ -90,8 +97,11 @@ export function TestPanel({ testResult, nodeName, nodeIcon }: TestPanelProps) { // Array of objects table const renderArrayOfObjectsTable = (data: any[], isNestedTable = false) => { + const normalizedData = (Array.isArray(data) ? data : []).map(item => { + return (item && typeof item === 'object' && 'json' in item) ? item.json : item; + }); const keysSet = new Set(); - data.slice(0, 100).forEach(item => { + normalizedData.slice(0, 100).forEach(item => { if (item && typeof item === 'object') Object.keys(item).forEach(k => keysSet.add(k)); }); const headers = Array.from(keysSet); @@ -100,7 +110,7 @@ export function TestPanel({ testResult, nodeName, nodeIcon }: TestPanelProps) { // Array of primitives return (
- {data.slice(0, 100).map((item, i) => ( + {normalizedData.slice(0, 100).map((item, i) => (
{i}
{String(item)}
@@ -111,30 +121,100 @@ export function TestPanel({ testResult, nodeName, nodeIcon }: TestPanelProps) { } return ( -
- - - - {headers.map(h => ( - - ))} - - - - {data.slice(0, 100).map((item, ri) => ( - - - {headers.map(h => { - const val = item?.[h]; - const display = typeof val === 'object' && val !== null ? JSON.stringify(val) : String(val ?? ''); - return ( - - ); - })} +
+
#{h}
{ri}{display}
+ + + + {headers.map(h => ( + + ))} - ))} - -
#{h}
+ + + {normalizedData.slice(0, 100).map((item, ri) => ( + + + {ri} + {headers.map(h => { + const val = item?.[h]; + const cellKey = `${isNestedTable ? 'n_' : 'r_'}${ri}_${h}`; + const isArrayOfObjects = Array.isArray(val) && val.length > 0 && typeof val[0] === 'object'; + + if (isArrayOfObjects) { + const isExpanded = !!expandedCells[cellKey]; + return ( + + + + ); + } + + let displayVal = val; + if (val && typeof val === 'object' && 'json' in val) { + displayVal = val.json; + } + const display = typeof displayVal === 'object' && displayVal !== null + ? JSON.stringify(displayVal) + : String(displayVal ?? ''); + return ( + + {display} + + ); + })} + + + {/* Expandable Sub-table Row */} + {headers.map(h => { + const val = item?.[h]; + const cellKey = `${isNestedTable ? 'n_' : 'r_'}${ri}_${h}`; + const isArrayOfObjects = Array.isArray(val) && val.length > 0 && typeof val[0] === 'object'; + if (!isArrayOfObjects || !expandedCells[cellKey]) return null; + + return ( + + +
+
+ + + {h} + ({val.length} {val.length === 1 ? 'record' : 'records'}) + + +
+ {renderArrayOfObjectsTable(val, true)} +
+ + + ); + })} +
+ ))} + +
); }; @@ -167,13 +247,66 @@ export function TestPanel({ testResult, nodeName, nodeIcon }: TestPanelProps) { // Direct 2D array if (Array.isArray(data) && data.length > 0 && Array.isArray(data[0])) { + // Handle multiple wires / groups (e.g., from Data Filter) + if (data.length > 1) { + return ( +
+ {data.map((wire: any[], wireIndex: number) => { + const records = wire.map((item: any) => (item && typeof item === 'object' && 'json' in item) ? item.json : item); + const groupName = metadata?.group_names?.[wireIndex] || `Group ${wireIndex + 1}`; + return ( +
+
+ {groupName} + {records.length} items +
+ {renderArrayOfObjectsTable(records)} +
+ ); + })} +
+ ); + } + + // Single wire fallback + const records = data[0].map((item: any) => (item && typeof item === 'object' && 'json' in item) ? item.json : item); + if (records.length === 1) { + const singleRecord = records[0]; + const keys = singleRecord && typeof singleRecord === 'object' ? Object.keys(singleRecord) : []; + const firstKey = keys[0]; + const isActionWrapper = Boolean(keys.length === 1 && firstKey && typeof singleRecord[firstKey] === 'object' && singleRecord[firstKey] !== null && !Array.isArray(singleRecord[firstKey])); + + const displayData = (isActionWrapper && firstKey) ? singleRecord[firstKey] : singleRecord; + + if (isActionWrapper) { + return ( + <> +
+ Result + 1 item +
+ {renderObjectTable(displayData)} + + ); + } else { + return ( + <> +
+ Table + 1 row +
+ {renderArrayOfObjectsTable(records)} + + ); + } + } return ( <>
Table - {data.length - 1} rows + {records.length} rows
- {renderSpreadsheetTable(data)} + {renderArrayOfObjectsTable(records)} ); } @@ -217,7 +350,7 @@ export function TestPanel({ testResult, nodeName, nodeIcon }: TestPanelProps) { }; return ( -
+
{/* Header */}
diff --git a/apps/web/app/components/ui/variable-panel.tsx b/apps/web/app/components/ui/variable-panel.tsx index f72400a..0f57022 100644 --- a/apps/web/app/components/ui/variable-panel.tsx +++ b/apps/web/app/components/ui/variable-panel.tsx @@ -4,6 +4,7 @@ import { PreviousNodeOutput, VariableDefinition } from "@/app/lib/types/node.typ import { useAppSelector } from "@/app/hooks/redux"; import { selectAllOutputs, NodeTestOutput } from "@/store/slices/nodeOutputSlice"; import { useState } from "react"; +import { NodeIcon } from "@/app/components/ui/NodeIcon"; interface VariablePanelProps { previousNodes: PreviousNodeOutput[]; @@ -150,8 +151,8 @@ export function VariablePanel({ previousNodes, onInsert, activeField, onTestNode const dataRows = rows.slice(1); return ( -
-
+
+
Spreadsheet Data
- - - - - {headers.map((header, colIndex) => { - const colPath = String(header).trim().toLowerCase().replace(/\s+/g, '_') || `column_${colIndex + 1}`; - return ( - - ); - })} - - - - {dataRows.slice(0, 50).map((row: any[], rowIndex: number) => ( - - - {headers.map((_, colIndex) => ( - - ))} +
+
# handleInsert(`{{${formattedNodeName}.${colPath}}}`)} - title={`Insert entire column array: {{${formattedNodeName}.${colPath}}}`} - > - {header || `Column ${colIndex + 1}`} -
{rowIndex + 1} handleInsert(`{{${formattedNodeName}.rows[${rowIndex + 1}][${colIndex}]}}`)} - title={`Insert cell {{${formattedNodeName}.rows[${rowIndex + 1}][${colIndex}]}}`} - > - {String(row[colIndex] ?? '')} -
+ + + + {headers.map((header, colIndex) => { + const colPath = String(header).trim().toLowerCase().replace(/\s+/g, '_') || `column_${colIndex + 1}`; + return ( + + ); + })} - ))} - -
# handleInsert(`{{${formattedNodeName}.${colPath}}}`)} + title={`Insert entire column array: {{${formattedNodeName}.${colPath}}}`} + > + {header || `Column ${colIndex + 1}`} +
+ + + {dataRows.slice(0, 50).map((row: any[], rowIndex: number) => ( + + {rowIndex + 1} + {headers.map((_, colIndex) => ( + handleInsert(`{{${formattedNodeName}.rows[${rowIndex + 1}][${colIndex}]}}`)} + title={`Insert cell {{${formattedNodeName}.rows[${rowIndex + 1}][${colIndex}]}}`} + > + {String(row[colIndex] ?? '')} + + ))} + + ))} + + +
); }; // Rendering for standard array of objects - const renderArrayTable = (nodeName: string, nodeId: string, dataArray: any[], isTested: boolean) => { + const renderArrayTable = (nodeName: string, nodeId: string, dataArray: any[], isTested: boolean, wireIndex?: number) => { const formattedNodeName = nodeId; // Find all unique keys across objects to form headers @@ -231,7 +234,7 @@ export function VariablePanel({ previousNodes, onInsert, activeField, onTestNode {index} handleInsert(`{{${formattedNodeName}[${index}]}}`)} + onClick={() => handleInsert(`{{${formattedNodeName}${wireIndex !== undefined ? `.[${wireIndex}]` : ''}[${index}]}}`)} > {String(item)} @@ -244,87 +247,125 @@ export function VariablePanel({ previousNodes, onInsert, activeField, onTestNode } return ( -
-
- JSON Array - {dataArray.length} items +
+
+
+ JSON Array + + + {dataArray.length} items + +
- - - - - {headers.map((header) => ( - + ))} + +
# { - const variableText = `{{${formattedNodeName}.map(item => item.${header})}}`; - - e.dataTransfer.setData('text/plain', variableText); - - e.dataTransfer.setData('application/buildflow-variable', JSON.stringify({ - nodeName: formattedNodeName, - path: header, - display: `${formattedNodeName}.${header}` - })) - - e.dataTransfer.effectAllowed = 'copy'; - - }} - key={header} - className={`px-3 py-2 border-r border-b border-[#2a2f3e] font-medium text-gray-400 truncate max-w-[150px] transition-colors +
+ + + + + {headers.map((header) => ( + - ))} - - - - {dataArray.slice(0, 50).map((item, rowIndex) => ( - - - {headers.map(header => { - const val = item?.[header]; - const displayStr = typeof val === 'object' && val !== null ? JSON.stringify(val) : String(val ?? ''); - return ( - + + + {dataArray.slice(0, 50).map((item, rowIndex) => ( + + + {headers.map(header => { + const val = item?.[header]; + const displayStr = typeof val === 'object' && val !== null ? JSON.stringify(val) : String(val ?? ''); + return ( + - ); - })} - - ))} - -
# { + const variableText = `{{${formattedNodeName}${wireIndex !== undefined ? `.[${wireIndex}].${header}` : `.${header}`}}}`; + + e.dataTransfer.setData('text/plain', variableText); + + e.dataTransfer.setData('application/buildflow-variable', JSON.stringify({ + nodeName: formattedNodeName, + path: wireIndex !== undefined ? `[${wireIndex}].${header}` : header, + display: variableText.replace('{{', '').replace('}}', '') + })) + + e.dataTransfer.effectAllowed = 'copy'; + + }} + key={header} + className={`px-3 py-2 border-r border-b border-[#2a2f3e] font-medium text-gray-400 truncate max-w-[150px] transition-colors ${activeField && isTested ? "hover:bg-blue-500/20 hover:text-blue-300 cursor-grab active:cursor-grabbing" : "cursor-not-allowed opacity-50"} `} - onClick={() => { - if (activeField && isTested) { - handleInsert(`{{${formattedNodeName}.map(item => item.${header})}}`); + onClick={() => { + if (activeField && isTested) { + handleInsert(`{{${formattedNodeName}${wireIndex !== undefined ? `.[${wireIndex}].${header}` : `.${header}`}}}`); + } + }} + title={ + !isTested + ? "Test node first to map data" + : activeField + ? `Insert entire column as array: {{${formattedNodeName}${wireIndex !== undefined ? `.[${wireIndex}].${header}` : `.${header}`}}}` + : "Select a field first" } - }} - title={ - !isTested - ? "Test node first to map data" - : activeField - ? `Insert entire column as array: {{${formattedNodeName}.map(item => item.${header})}}` - : "Select a field first" - } - > - {header} -
{rowIndex} + {header} + + ))} +
{rowIndex} { - if (activeField && isTested) { - handleInsert(`{{${formattedNodeName}[${rowIndex}].${header}}}`); + onClick={() => { + if (activeField && isTested) { + handleInsert(`{{${formattedNodeName}${wireIndex !== undefined ? `.[${wireIndex}].[${rowIndex}].json.${header}` : `.[${rowIndex}].json.${header}`}}}`); + } + }} + title={ + !isTested + ? "Test node first to map data" + : activeField + ? `Insert {{${formattedNodeName}${wireIndex !== undefined ? `.[${wireIndex}].[${rowIndex}].json.${header}` : `.[${rowIndex}].json.${header}`}}}` + : "Select a field first" } - }} - title={ - !isTested - ? "Test node first to map data" - : activeField - ? `Insert {{${formattedNodeName}[${rowIndex}].${header}}}` - : "Select a field first" - } - > - {displayStr} -
+ > + {displayStr} + + ); + })} +
+
); }; @@ -344,7 +385,7 @@ export function VariablePanel({ previousNodes, onInsert, activeField, onTestNode } return ( -
+
{/* Panel Header */}
@@ -392,30 +433,29 @@ export function VariablePanel({ previousNodes, onInsert, activeField, onTestNode `} onClick={() => setExpandedNodeId(isExpanded ? null : node.nodeId)} > -
-
- {node.icon ? ( - {node.nodeName} - ) : ( -
- -
- )} +
+
+ {isTested && ( - + )}
-
-

+
+

{node.nodeName}

-

+

{isTested ? 'Data available' : 'Using sample schema'}

-
+
{onTestNode && !node.nodeName.toLowerCase().includes('webhook') && ( -
- - {/* Body */} -
- {/* Node info */} -
-
-
- ID - {selectedNode.id} -
-
- Type - {selectedNode.type ?? "Trigger"} -
-
+
+ + {/* Left Panel: Variable / Data Mapping */} + +
+
- {/* Dynamic Form Block */} - {(() => { - const nodeConfig = getNodeConfig( - selectedNode.name || selectedNode.actionType - ); - if (!nodeConfig) { - return ( -

- No config found for {selectedNode.name} -

- ); - } - if ((nodeConfig.fields || []).length === 0) { - return ( -
-
✅
-

- {nodeConfig.label} -

-

{nodeConfig.description}

- {nodeConfig.id === "webhook" && ( -
-

- Webhook URL: + + + {/* Drag Handle 1 */} + +

+ + + {/* Center Panel: Node Configuration Form */} + +
+ {/* Header */} +
+

+ + {selectedNode.name} +

+ +
+ + {/* Body */} +
+ {/* Node info */} +
+
+
+ ID + {selectedNode.id} +
+
+ Type + {selectedNode.type ?? "Trigger"} +
+
+
+ {/* Dynamic Form Block */} + {(() => { + const nodeConfig = getNodeConfig( + selectedNode.name || selectedNode.actionType + ); + if (!nodeConfig) { + return ( +

+ No config found for {selectedNode.name} +

+ ); + } + if ((nodeConfig.fields || []).length === 0) { + return ( +
+
✅
+

+ {nodeConfig.label}

-
- - {`${HOOKS_URL}/${userId}/${selectedNode.id}`} - - -
-

- Copy this URL to trigger the workflow -

+

+ Webhook URL: +

+
+ + {`${HOOKS_URL}/${userId}/${selectedNode.id}`} + + +
+

+ Copy this URL to trigger the workflow +

+
+ )}
- )} -
- ); - } - return ( -
- {nodeConfig.fields - .filter((field) => isFieldVisible(field, config)) - .map((field) => renderField(field, nodeConfig))} -
- ); - })()} + ); + } + return ( +
+ {nodeConfig.fields + .filter((field) => isFieldVisible(field, config)) + .map((field) => renderField(field, nodeConfig))} +
+ ); + })()} - {/* Test Result Display */} - {/* {testResult && ( + {/* Test Result Display */} + {/* {testResult && (
✅ Test Output @@ -957,50 +989,63 @@ export default function ConfigModal({
)} */} - {/* Test Error Display */} - {/* {nodeTestOutput?.error && ( + {/* Test Error Display */} + {/* {nodeTestOutput?.error && (
❌ Test Failed

{nodeTestOutput.error}

)} */} -
- - {/* Footer */} -
- {!selectedNode.name.includes("webhook") && - - } -
- +
+ + {/* Footer */} +
+ {!selectedNode.name.includes("webhook") && + + } +
+ +
+
-
-
- + + + {/* Drag Handle 2 */} + +
+ + + {/* Right Panel: Test Output */} + +
+ +
+
+
); diff --git a/apps/web/app/workflows/[id]/page.tsx b/apps/web/app/workflows/[id]/page.tsx index 2b4dc1a..8014460 100644 --- a/apps/web/app/workflows/[id]/page.tsx +++ b/apps/web/app/workflows/[id]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { useParams } from "next/navigation"; import { ReactFlow, @@ -11,6 +11,9 @@ import { useNodesState, useEdgesState, NodeChange, + addEdge, + Connection, + EdgeChange } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; import { PreviousNodeOutput } from "../../lib/types/node.types"; @@ -40,10 +43,10 @@ export default function WorkflowCanvas() { // context rebuilding for test function - const buildTestContext = ()=>{ + const buildTestContext = () => { const context: Record = {}; - for (const [nodeId, testOutput] of Object.entries(allTestedOutputs)){ - if(testOutput.success && testOutput.data){ + for (const [nodeId, testOutput] of Object.entries(allTestedOutputs)) { + if (testOutput.success && testOutput.data) { const normalizedName = testOutput.nodeName.toLowerCase().replace(/\s+/g, '_') context[normalizedName] = testOutput.data } @@ -51,46 +54,47 @@ export default function WorkflowCanvas() { return context; } - const testNodeFromCanvas = async(nodeId: string, nodeName: string, nodeType: string)=>{ - dispatch(setNodeLoading({nodeId: nodeId, loading: true})); - try{ + const testNodeFromCanvas = async (nodeId: string, nodeName: string, nodeType: string) => { + dispatch(setNodeLoading({ nodeId: nodeId, loading: true })); + try { const isTrigger = reduxWorkflow.data.trigger?.TriggerId === nodeId; - const savedConfig = isTrigger ? reduxWorkflow.data.trigger?.Config : - reduxWorkflow.data.nodes.find(n=> n.NodeId === nodeId)?.Config ; + const savedConfig = isTrigger ? reduxWorkflow.data.trigger?.Config : + reduxWorkflow.data.nodes.find(n => n.NodeId === nodeId)?.Config; console.log(savedConfig, "-- from 57852") const interpolationContext = buildTestContext(); const resolvedConfig = resolveConfigVariables(savedConfig, interpolationContext); console.log(resolvedConfig, "--from 60") - const response = await api.execute.node(nodeId,resolvedConfig); + const response = await api.execute.node(nodeId, resolvedConfig); dispatch(setNodeOutput({ nodeId: nodeId, nodeName: nodeName, nodeType: nodeType, - data: response, + data: response.output, + metadata: response.metadata, testedAt: Date.now(), success: true, variables: [] })) toast.success(`Tested ${nodeName} successfully!`); - }catch(err: any){ + } catch (err: any) { console.log(err, "from 73") toast.error(`Test failed: ${err.message}`); - dispatch(setNodeOutput({ - nodeId, - nodeName, - nodeType, - data: null, - variables: [], - testedAt: Date.now(), - success: false, - error: err.message - })); + dispatch(setNodeOutput({ + nodeId, + nodeName, + nodeType, + data: null, + variables: [], + testedAt: Date.now(), + success: false, + error: err.message + })); } } const getPreviousNodes = ( - selectedNodeId: string, - allNodes: Node[], - allEdges: Edge[] + selectedNodeId: string, + allNodes: Node[], + allEdges: Edge[] ): PreviousNodeOutput[] => { // BFS backward through the graph to find ALL ancestor nodes const visited = new Set(); @@ -132,17 +136,17 @@ export default function WorkflowCanvas() { const handleExecute = async () => { const unConfigured = nodes.filter( - n=> !n.data.isPlaceholder && !n.data.isConfigured + n => !n.data.isPlaceholder && !n.data.isConfigured ); - if(unConfigured.length > 0){ + if (unConfigured.length > 0) { setError(`Configure these nodes first: ${unConfigured.map(n => n.data.label).join(', ')}`); return } setLoading(true); try { - const data = await api.workflows.execute({workflowId}) + const data = await api.workflows.execute({ workflowId }) console.log("This is from the Execute Button", data) toast.success("Execution Started") } @@ -175,7 +179,9 @@ export default function WorkflowCanvas() { const [configOpen, setConfigOpen] = useState(false); const [selectedNode, setSelectedNode] = useState(null); const [error, setError] = useState(null); - const [loading, setLoading] = useState(false) + const [loading, setLoading] = useState(false); + const [branchSourceNodeId, setBranchSourceNodeId] = useState(null); + const [branchSourceHandleId, setBranchSourceHandleId] = useState(null); const nodeTypes = { customNode: BaseNode, }; @@ -194,27 +200,27 @@ export default function WorkflowCanvas() { } console.log("The Detaisl of Selected Node is ", selectedNode) - function checkIsConfigure(nodeName: string, config: any): boolean{ + function checkIsConfigure(nodeName: string, config: any): boolean { const nodeConfig = getNodeConfig(nodeName); - if(!nodeConfig || !nodeConfig.fields) return true; - const requiredFields = nodeConfig.fields.filter((f: any)=> f.required); - if(requiredFields.length === 0) return true; - return requiredFields.every((f:any)=> config?.[f.name] !== undefined && config?.[f.name] !== '' ); + if (!nodeConfig || !nodeConfig.fields) return true; + const requiredFields = nodeConfig.fields.filter((f: any) => f.required); + if (requiredFields.length === 0) return true; + return requiredFields.every((f: any) => config?.[f.name] !== undefined && config?.[f.name] !== ''); } - useEffect(()=>{ + useEffect(() => { setNodes(prev => prev.map(node => { - if(node.data?.isPlaceholder) return node; - if(node.data?.nodeType === 'trigger'){ + if (node.data?.isPlaceholder) return node; + if (node.data?.nodeType === 'trigger') { const reduxConfig = reduxWorkflow.data.trigger?.Config; const name = reduxWorkflow.data.trigger?.name || ""; - return { ...node, data: { ...node.data, isConfigured: checkIsConfigure(name, reduxConfig)}} + return { ...node, data: { ...node.data, isConfigured: checkIsConfigure(name, reduxConfig) } } } - if(node.data.nodeType === 'action'){ - const reduxNode = reduxWorkflow.data.nodes.find(n=> n.NodeId === node.id); - if(!reduxNode) return node; - return { ...node, data: { ...node.data, isConfigured: checkIsConfigure(reduxNode.name, reduxNode.Config)}}; + if (node.data.nodeType === 'action') { + const reduxNode = reduxWorkflow.data.nodes.find(n => n.NodeId === node.id); + if (!reduxNode) return node; + return { ...node, data: { ...node.data, isConfigured: checkIsConfigure(reduxNode.name, reduxNode.Config) } }; } return node; })); @@ -223,14 +229,27 @@ export default function WorkflowCanvas() { useEffect(() => { const loadWorkflows = async () => { try { - if(reduxWorkflow.data.workflowId === workflowId){ + if (reduxWorkflow.data.workflowId === workflowId) { const { trigger, nodes: reduxNodes, edges: reduxEdges } = reduxWorkflow.data if (!trigger) { - setError("No trigger found in workflow data, so start with selecting the trigger for the workflow"); + setNodes([{ + id: "trigger-placeholder", + type: "customNode", + position: { x: 250, y: 50 }, + data: { + label: "Add Trigger", + icon: "➕", + isPlaceholder: true, + nodeType: "trigger", + onConfigure: () => setTriggerOpen(true), + }, + }]); + setEdges([]); + setError(null); return; } - const triggerPosition = ensurePosition(trigger.position, DEFAULT_TRIGGER_POSITION ) + const triggerPosition = ensurePosition(trigger.position, DEFAULT_TRIGGER_POSITION) const triggerNode = { id: trigger.TriggerId, type: "customNode", @@ -246,12 +265,17 @@ export default function WorkflowCanvas() { name: trigger.name, icon: trigger.icon }), - onTest: ( trigger.name.toLowerCase().includes('webhook') ? undefined : ()=> testNodeFromCanvas(trigger.TriggerId, trigger.name, "trigger") ), + onTest: (trigger.name.toLowerCase().includes('webhook') ? undefined : () => testNodeFromCanvas(trigger.TriggerId, trigger.name, "trigger")), + onAddChild: (sourceHandleId?: string) => { + setBranchSourceNodeId(trigger.TriggerId); + setBranchSourceHandleId(sourceHandleId || null); + setActionOpen(true); + } }, }; console.log(JSON.stringify(reduxNodes), "from 236") - const transformedNodes = reduxNodes.map((node) =>({ + const transformedNodes = reduxNodes.map((node) => ({ id: node.NodeId, type: "customNode", position: ensurePosition(node.position, { @@ -260,7 +284,7 @@ export default function WorkflowCanvas() { }), data: { label: node.name || "Unknown", - icon: node.icon || "⚙️", + icon: node.icon || "⚙️", nodeType: "action", isConfigured: checkIsConfigure(node.name, node.Config), onConfigure: () => @@ -271,8 +295,13 @@ export default function WorkflowCanvas() { actionType: node.AvailableNodeID, icon: node.icon }), - onTest: ()=> testNodeFromCanvas(node.NodeId, node.name, "action") + onTest: () => testNodeFromCanvas(node.NodeId, node.name, "action"), + onAddChild: (sourceHandleId?: string) => { + setBranchSourceNodeId(node.NodeId); + setBranchSourceHandleId(sourceHandleId || null); + setActionOpen(true); } + } })) const lastNode = @@ -310,7 +339,7 @@ export default function WorkflowCanvas() { const lastActionNode = reduxNodes.length > 0 ? reduxNodes[reduxNodes.length - 1] : null; const sourceNodeId = lastActionNode ? lastActionNode.NodeId : trigger.TriggerId - + const cleanReduxEdges = reduxEdges.filter(e => !e.target.startsWith('action-placeholder-')) const newEdges = [ @@ -325,10 +354,10 @@ export default function WorkflowCanvas() { } - else{ - const workflows = await api.workflows.get(workflowId); - - // Defensive: Default to empty arrays if not present + else { + const workflows = await api.workflows.get(workflowId); + + // Defensive: Default to empty arrays if not present const dbNodes = Array.isArray(workflows?.data?.Data?.nodes) ? workflows.data.Data.nodes : []; @@ -338,12 +367,25 @@ export default function WorkflowCanvas() { : []; const Trigger = workflows?.data?.Data?.Trigger; - + if (!Trigger) { - setError("No trigger found in workflow data, so start with selecting the trigger for the workflow"); + setNodes([{ + id: "trigger-placeholder", + type: "customNode", + position: { x: 250, y: 50 }, + data: { + label: "Add Trigger", + icon: "➕", + isPlaceholder: true, + nodeType: "trigger", + onConfigure: () => setTriggerOpen(true), + }, + }]); + setEdges([]); + setError(null); return; } - + // store updating dispatch( workflowActions.setWorkflowFromBackend({ @@ -372,8 +414,11 @@ export default function WorkflowCanvas() { name: Trigger.name, icon: Trigger.icon }), - onTest: ( Trigger.name.toLowerCase() === 'webhook' ? undefined : ()=> testNodeFromCanvas(Trigger.TriggerId, Trigger.name, "trigger") ) - + onTest: (Trigger.name.toLowerCase() === 'webhook' ? undefined : () => testNodeFromCanvas(Trigger.TriggerId, Trigger.name, "trigger")), + onAddChild: () => { + setBranchSourceNodeId(Trigger.id); + setActionOpen(true); + } }, }; @@ -398,55 +443,21 @@ export default function WorkflowCanvas() { icon: node.icon, actionType: node.AvailableNodeId, }), - onTest: ()=> testNodeFromCanvas(node.NodeId, node.name, "action") + onTest: () => testNodeFromCanvas(node.NodeId, node.name, "action"), + onAddChild: (sourceHandleId?: string) => { + setBranchSourceNodeId(node.id); + setBranchSourceHandleId(sourceHandleId || null); + setActionOpen(true); + } }, })); - // 4. Calculate placeholder position: use last action node, fallback to trigger - const lastNode = - transformedNodes.length > 0 - ? transformedNodes[transformedNodes.length - 1] - : triggerNode; - - const lastPosition = ensurePosition( - lastNode?.position, - transformedNodes.length > 0 - ? { x: triggerPosition.x + 350, y: triggerPosition.y + 150 } - : triggerPosition - ); + // 4. Combine nodes + const finalNodes = [triggerNode, ...transformedNodes]; - const placeholderPosition = { - x: lastPosition.x + 550, - y: lastPosition.y, - }; - - const actionPlaceholder = { - id: `action-placeholder-${Date.now()}`, - type: "customNode", - position: placeholderPosition, - data: { - label: "Add Action", - icon: "➕", - isPlaceholder: true, - nodeType: "action", - onConfigure: () => setActionOpen(true), - }, - }; - - // 5. Combine nodes - const finalNodes = [triggerNode, ...transformedNodes, actionPlaceholder]; - - // 6. Manage edges + // 5. Manage edges let finalEdges = Array.isArray(dbEdges) ? [...dbEdges] : []; - const placeholderEdge = { - id: `e-${lastNode.id}-${actionPlaceholder.id}`, - source: lastNode.id, - target: actionPlaceholder.id, - type: "default", - animated: true, - }; - if (dbEdges.length === 0 && transformedNodes.length > 0) { const triggerEdge = { id: `e-${triggerNode.id}-${transformedNodes[0].id}`, @@ -457,8 +468,6 @@ export default function WorkflowCanvas() { finalEdges.push(triggerEdge); } - finalEdges.push(placeholderEdge); - setNodes(finalNodes); setEdges(finalEdges); setError(null); @@ -485,30 +494,66 @@ export default function WorkflowCanvas() { onNodesChange(changes); }; - const nodeChangeDb = async (event: React.MouseEvent, node: Node)=>{ + const onConnect = (connection: Connection) => { + if (connection.source === connection.target) { + toast.error("A node cannot connect to itself.") + return + } + const targetNode = nodes.find((n) => n.id === connection.target) + if (targetNode?.data.nodeType === 'trigger') { + toast.error("Triggers cannot receive inputs.") + return + } + if (targetNode?.data.isPlaceholder) { + toast.error("Please configure the node before connecting.") + return + } + if (edges.some(e => + e.target === connection.target && !e.target.startsWith("action-placeholder-") + )) { + toast.error("Each node can only accept 1 incoming connection (1 -> M).") + return + } + + const newEdge: Edge = { + ...connection, + id: `e-${connection.source}-${connection.target}-${Date.now()}`, + animated: true, + }; + const updated = addEdge(newEdge, edges); + setEdges(updated); + + const cleanEdges = updated + .filter((e) => !e.target.startsWith("action-placeholder-") && e.target !== "action-holder") + .map((e) => ({ id: e.id, source: e.source, target: e.target, sourceHandle: e.sourceHandle ?? null, targetHandle: e.targetHandle ?? null })); + dispatch(workflowActions.setEdge(cleanEdges)); + toast.success("Branch connected successfully!"); + }; + + const nodeChangeDb = async (event: React.MouseEvent, node: Node) => { try { if (node.data?.nodeType === "trigger") { - // await api.triggers.update({ - // TriggerId: node.id, - // Config: { - // ...(typeof node.data.config === "object" && node.data.config !== null - // ? node.data.config - // : {}), - // position: node.position, - // }, - // }); + // await api.triggers.update({ + // TriggerId: node.id, + // Config: { + // ...(typeof node.data.config === "object" && node.data.config !== null + // ? node.data.config + // : {}), + // position: node.position, + // }, + // }); dispatch(workflowActions.updateTriggerPosition(node.position)) - } - else { - // await api.nodes.update({ - // NodeId: node.id, - // position: node.position, - // }); - dispatch(workflowActions.updateNodePosition({ - nodeId:node.id, - position: node.position - })) - } + } + else { + // await api.nodes.update({ + // NodeId: node.id, + // position: node.position, + // }); + dispatch(workflowActions.updateNodePosition({ + nodeId: node.id, + position: node.position + })) + } } catch (err: any) { setError( err?.message ?? @@ -533,14 +578,17 @@ export default function WorkflowCanvas() { ); const nextIndex = currentActionNodes.length; - // Place new action node after last action (or at default offset if none) - const ACTION_NODE_DEFAULT_X = 350; - const ACTION_NODE_DEFAULT_Y = 400; - const ACTION_NODE_OFFSET_X = 320; - const newNodePosition = { - x: ACTION_NODE_DEFAULT_X + ACTION_NODE_OFFSET_X * nextIndex, - y: ACTION_NODE_DEFAULT_Y, - }; + // Use branchSourceNodeId if branching, otherwise fall back to linear + const actualSourceNodeId = branchSourceNodeId || (currentActionNodes.length > 0 ? currentActionNodes[currentActionNodes.length - 1]!.id : triggerNode.id); + const actualSourceNode = nodes.find(n => n.id === actualSourceNodeId); + + // Check how many children this source node has to vertically stack them + const childCount = edges.filter(e => e.source === actualSourceNodeId && !e.target.startsWith('action-placeholder-')).length; + + const newNodePosition = actualSourceNode ? { + x: actualSourceNode.position.x + 350, + y: actualSourceNode.position.y + (childCount * 170), + } : { x: 350, y: 400 }; try { const result = await api.nodes.create({ @@ -560,11 +608,11 @@ export default function WorkflowCanvas() { // : triggerNode.id // const filterEdges = reduxState.edges.filter(e => !e.target.startsWith('action-placeholder-')) - const sourceNodeId = currentActionNodes.length > 0 ? currentActionNodes[currentActionNodes.length - 1]!.id : triggerNode.id - + const sourceNodeId = actualSourceNodeId; + const cleanReduxEdges = edges.filter( - e => !e.target.startsWith('action-placeholder-') && - e.target !== 'action-holder' + e => !e.target.startsWith('action-placeholder-') && + e.target !== 'action-holder' ) // store updating @@ -598,38 +646,21 @@ export default function WorkflowCanvas() { actionType: action.id, icon: action.icon }), - onTest: ()=> testNodeFromCanvas(actionId, action.name, "action") - }, - }; - - // Place the action placeholder after the new node, prevent overlap - const actionPlaceholderPosition = { - x: ACTION_NODE_DEFAULT_X + ACTION_NODE_OFFSET_X * (nextIndex + 1) + 400, - y: ACTION_NODE_DEFAULT_Y, - }; - - const actionPlaceholder = { - id: `action-placeholder-${Date.now()}`, - type: "customNode", - position: actionPlaceholderPosition, - data: { - label: "Add Action", - icon: "➕", - isPlaceholder: true, - nodeType: "action", - config: {}, - onConfigure: () => setActionOpen(true), + onTest: () => testNodeFromCanvas(actionId, action.name, "action"), + onAddChild: (sourceHandleId?: string) => { + setBranchSourceNodeId(actionId); + setBranchSourceHandleId(sourceHandleId || null); + setActionOpen(true); + } }, }; - // const triggerId = triggerNode.id; - setNodes((prevNodes) => { - // Remove any existing action placeholder nodes + // Remove any existing action placeholder nodes (cleanup) const filtered = prevNodes.filter( (n) => !(n.data.isPlaceholder && n.data.nodeType === "action") ); - return [...filtered, newNode, actionPlaceholder]; + return [...filtered, newNode]; }); // const reduxState = store.getState().workflow.data @@ -642,13 +673,20 @@ export default function WorkflowCanvas() { const newEdges = [ ...cleanReduxEdges, - {id: `e-action-${sourceNodeId}-${actionId}`, source: sourceNodeId, target: actionId }, - { id: `e-action-${actionId}-placeholder`, source: actionId, target: actionPlaceholder.id }, + { + id: `e-action-${sourceNodeId}-${actionId}`, + source: sourceNodeId, + target: actionId, + sourceHandle: actualSourceNode?.data.nodeType === "trigger" ? "t-out" : (branchSourceHandleId || "out-0"), + targetHandle: "a-in" + } ] setEdges(newEdges); const reduxEdges = newEdges.filter(e => !e.target.startsWith('action-placeholder-') && e.target !== 'action-holder') dispatch(workflowActions.setEdge(reduxEdges)) + setBranchSourceNodeId(null); + setBranchSourceHandleId(null); setActionOpen(false); setError(null); } catch (err: any) { @@ -670,7 +708,7 @@ export default function WorkflowCanvas() { }); const triggerId = result.data.data.id as string; - + const newNode = { id: triggerId, @@ -690,35 +728,17 @@ export default function WorkflowCanvas() { type: "trigger", icon: trigger.icon }), - onTest: ( trigger.name.toLowerCase() === 'webhook' ? undefined : ()=> testNodeFromCanvas(trigger.TriggerId, trigger.name, "trigger") ) - - }, - }; - - const actionPlaceholder = { - id: "action-holder", - type: "customNode", - position: DEFAULT_ACTION_POSITION, - data: { - label: "Add Action", - icon: "➕", - isPlaceholder: true, - nodeType: "action", - config: {}, - onConfigure: () => setActionOpen(true), + onTest: (trigger.name.toLowerCase() === 'webhook' ? undefined : () => testNodeFromCanvas(trigger.TriggerId, trigger.name, "trigger")), + onAddChild: (sourceHandleId?: string) => { + setBranchSourceNodeId(triggerId); + setBranchSourceHandleId(sourceHandleId || null); + setActionOpen(true); + } }, }; - setNodes([newNode, actionPlaceholder]); - const newEdge = [ - { - id: "e1", - source: triggerId, - target: "action-holder", - }, - ] - setEdges(newEdge); - const reduxEdge = newEdge.filter(e => e.target !== 'action-holder') + setNodes([newNode]); + setEdges([]); dispatch(workflowActions.setWorkflowTrigger({ TriggerId: triggerId, @@ -729,7 +749,7 @@ export default function WorkflowCanvas() { position: DEFAULT_TRIGGER_POSITION, AvailableTriggerID: trigger.id })) - dispatch(workflowActions.setEdge(reduxEdge)) + dispatch(workflowActions.setEdge([])) setTriggerOpen(false); setError(null); } catch (err: any) { @@ -800,17 +820,31 @@ export default function WorkflowCanvas() {
)}
- - - - {/* {children} */} - -
- + + + {/* {children} */} + +
+ { + onEdgesChange(changes); + if (changes.some(c => c.type === 'remove')) { + // Give ReactFlow a tick to update edges state, then dispatch + setTimeout(() => { + setEdges(currentEdges => { + const clean = currentEdges.filter(e => !e.target.startsWith('action-placeholder-') && e.target !== 'action-holder') + .map(e => ({ id: e.id, source: e.source, target: e.target, sourceHandle: e.sourceHandle ?? null, targetHandle: e.targetHandle ?? null })); + dispatch(workflowActions.setEdge(clean)); + return currentEdges; + }); + }, 0); + } + }} + onConnect={onConnect} nodeTypes={nodeTypes} onNodeDragStop={nodeChangeDb} fitView @@ -824,7 +858,7 @@ export default function WorkflowCanvas() { onClick={batchSave} className="border bg-white text-black font-bold p-4 shadow-lg px-12 rounded-2xl" > - { displayStatus } + {displayStatus}
- + { - // setNodes(prev=> prev.map(n=> - // n.id === nodeId ? { ...n, data: {...n.data, isConfigured}} : n - // )) - // }} - // onSave={async (nodeId: string, config: any, userId: string) => { - // try { - // const triggerNode = nodes.find( - // (n) => n.data.nodeType === "trigger" - // ); - // const isTrigger = triggerNode?.id === nodeId; - - // if (isTrigger) { - // // await api.triggers.update({ - // // TriggerId: nodeId, - // // Config: config, - // // }); - // dispatch(workflowActions.updateTriggerConfig({config})) - // } else { - // // await api.nodes.update({ - // // NodeId: nodeId, - // // Config: config, - // // }); - // dispatch(workflowActions.updateNodeConfig({nodeId, config})) - // } - - // setNodes((prevNodes) => - // prevNodes.map((node) => - // node.id === nodeId - // ? { - // ...node, - // data: { ...node.data, config, isConfigured: true }, - // } - // : node - // ) - // ); - // setError(null); - // } catch (err: any) { - // setError( - // err?.message ?? - // "Failed to save configuration. Please try again." - // ); - // } - // }} + // onNodeConfigured={(nodeId, isConfigured)=>{ + // setNodes(prev=> prev.map(n=> + // n.id === nodeId ? { ...n, data: {...n.data, isConfigured}} : n + // )) + // }} + // onSave={async (nodeId: string, config: any, userId: string) => { + // try { + // const triggerNode = nodes.find( + // (n) => n.data.nodeType === "trigger" + // ); + // const isTrigger = triggerNode?.id === nodeId; + + // if (isTrigger) { + // // await api.triggers.update({ + // // TriggerId: nodeId, + // // Config: config, + // // }); + // dispatch(workflowActions.updateTriggerConfig({config})) + // } else { + // // await api.nodes.update({ + // // NodeId: nodeId, + // // Config: config, + // // }); + // dispatch(workflowActions.updateNodeConfig({nodeId, config})) + // } + + // setNodes((prevNodes) => + // prevNodes.map((node) => + // node.id === nodeId + // ? { + // ...node, + // data: { ...node.data, config, isConfigured: true }, + // } + // : node + // ) + // ); + // setError(null); + // } catch (err: any) { + // setError( + // err?.message ?? + // "Failed to save configuration. Please try again." + // ); + // } + // }} /> {}} + onExecutionFetch={() => { }} />
); diff --git a/apps/web/package.json b/apps/web/package.json index 1e2a3b9..b2415ee 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -36,11 +36,12 @@ "react-dialog": "link:@types/@radix-ui/react-dialog", "react-dom": "^19.1.1", "react-redux": "^9.2.0", + "react-resizable-panels": "^4.14.1", + "recharts": "^2.15.1", "redux-persist": "^6.0.0", "server-only": "^0.0.1", "sonner": "^2.0.7", - "zod": "^3.25.76", - "recharts": "^2.15.1" + "zod": "^3.25.76" }, "devDependencies": { "@tailwindcss/postcss": "^4.1.11", diff --git a/apps/web/store/slices/nodeOutputSlice.ts b/apps/web/store/slices/nodeOutputSlice.ts index a94d52c..36c757f 100644 --- a/apps/web/store/slices/nodeOutputSlice.ts +++ b/apps/web/store/slices/nodeOutputSlice.ts @@ -11,6 +11,7 @@ export interface NodeTestOutput { testedAt: number; // Timestamp success: boolean; error?: string; + metadata?: Record; // Optional metadata from execution result } export interface NodeOutputState { diff --git a/apps/web/store/slices/workflowSlice.ts b/apps/web/store/slices/workflowSlice.ts index 22d000c..fd04e10 100644 --- a/apps/web/store/slices/workflowSlice.ts +++ b/apps/web/store/slices/workflowSlice.ts @@ -31,6 +31,8 @@ interface EdgeItem { id: string; source: string; target: string; + sourceHandle?: string | null; + targetHandle?: string | null; } type Nodes = NodeItem[]; @@ -102,16 +104,16 @@ const workflowSlice = createSlice({ description: backendData?.description ?? null, trigger: backendTrigger ? { - TriggerId: backendTrigger?.id ?? "", - name: backendTrigger?.name ?? "", - type: backendTrigger?.type ?? "", - icon: backendTrigger?.icon ?? null, - Config: backendTrigger?.config || {}, - position: - backendTrigger?.Position || DEFAULT_TRIGGER_POSITION, - AvailableTriggerID: - backendTrigger?.AvailableTriggerID ?? "", - } + TriggerId: backendTrigger?.id ?? "", + name: backendTrigger?.name ?? "", + type: backendTrigger?.type ?? "", + icon: backendTrigger?.icon ?? null, + Config: backendTrigger?.config || {}, + position: + backendTrigger?.Position || DEFAULT_TRIGGER_POSITION, + AvailableTriggerID: + backendTrigger?.AvailableTriggerID ?? "", + } : null, nodes: backendNodes.map((n: any) => ({ NodeId: n?.id ?? "", @@ -133,7 +135,7 @@ const workflowSlice = createSlice({ state.changedNodeIds = []; }, - setWorkflow(state, action: PayloadAction){ + setWorkflow(state, action: PayloadAction) { state.data = action.payload; state.isChanged = { trigger: false, nodes: false, edges: false }; state.changedNodeIds = [] @@ -147,49 +149,49 @@ const workflowSlice = createSlice({ addWorkflowNode(state, action: PayloadAction) { state.data.nodes.push(action.payload) state.isChanged.nodes = true - if(!state.changedNodeIds?.includes(action.payload.NodeId)) + if (!state.changedNodeIds?.includes(action.payload.NodeId)) state.changedNodeIds?.push(action.payload.NodeId) }, - updateNodePosition(state, action: PayloadAction<{nodeId: string, position: Position}>){ - const node = state.data.nodes.find((n)=> n.NodeId === action.payload.nodeId) + updateNodePosition(state, action: PayloadAction<{ nodeId: string, position: Position }>) { + const node = state.data.nodes.find((n) => n.NodeId === action.payload.nodeId) - if(node){ + if (node) { node.position = action.payload.position state.isChanged.nodes = true - if(!state.changedNodeIds?.includes(action.payload.nodeId)) + if (!state.changedNodeIds?.includes(action.payload.nodeId)) state.changedNodeIds?.push(action.payload.nodeId) } - + }, - updateNodeConfig(state, action: PayloadAction<{nodeId: string,config: any}>){ - const node = state.data.nodes.find((n)=> n.NodeId === action.payload.nodeId); - if(node){ + updateNodeConfig(state, action: PayloadAction<{ nodeId: string, config: any }>) { + const node = state.data.nodes.find((n) => n.NodeId === action.payload.nodeId); + if (node) { node.Config = action.payload.config state.isChanged.nodes = true - if(!state.changedNodeIds?.includes(action.payload.nodeId)) + if (!state.changedNodeIds?.includes(action.payload.nodeId)) state.changedNodeIds?.push(action.payload.nodeId) } }, - updateTriggerPosition(state, action: PayloadAction){ - if(state.data.trigger){ + updateTriggerPosition(state, action: PayloadAction) { + if (state.data.trigger) { state.data.trigger.position = action.payload state.isChanged.trigger = true } }, - updateTriggerConfig(state, action: PayloadAction<{config: any}>){ - if(state.data.trigger){ + updateTriggerConfig(state, action: PayloadAction<{ config: any }>) { + if (state.data.trigger) { state.data.trigger.Config = action.payload.config state.isChanged.trigger = true } }, - setEdge(state, action: PayloadAction){ + setEdge(state, action: PayloadAction) { state.data.edges = action.payload state.isChanged.edges = true }, - markSynced(state){ + markSynced(state) { state.isChanged = { trigger: false, nodes: false, diff --git a/apps/worker/src/engine/executor.ts b/apps/worker/src/engine/executor.ts index c9f61d5..e6c2a68 100644 --- a/apps/worker/src/engine/executor.ts +++ b/apps/worker/src/engine/executor.ts @@ -55,6 +55,7 @@ export async function executeWorkflow( credentials: true, }, }, + Trigger: true, }, }, nodeExecutions: true @@ -183,30 +184,65 @@ export async function executeWorkflow( // console.log("output: ", JSON.stringify(execute)); // } - const firstActionNode = data.workflow.nodes.find(n => n.stage === 0) - if (!firstActionNode) { - console.log("No Trigger node found!") - await prismaClient.workflowExecution.update({ - where: { id: workflowExecutionId }, - data: { - status: "Failed", - completedAt: new Date(), - error: "No Trigger node found!" - } - }) - return + const allEdges = (data.workflow.Edges as any[]) || []; + const triggerId = data.workflow.Trigger?.id; + const triggerOutgoingEdges = triggerId ? allEdges.filter(e => e.source === triggerId) : []; + + // Inject Trigger metadata into execution context so downstream nodes can access {{webhook.body}} + if (data.workflow.Trigger) { + executedNodeOutputs.push({ + nodeName: data.workflow.Trigger.name, + nodeId: data.workflow.Trigger.id, + outputData: data.metadata, + }); + console.log(`[Interpolation] Injected Trigger payload into context: ${JSON.stringify(data.metadata)}`); } - const queue: QueueItem[] = [{ - nodeId: firstActionNode.id, - inputData: data?.metadata - }] + const queue: QueueItem[] = []; + const executedNodeIds = new Set(); + + if (triggerOutgoingEdges.length > 0) { + for (const edge of triggerOutgoingEdges) { + queue.push({ + nodeId: edge.target, + inputData: data?.metadata + }); + } + } else { + // Legacy fallback + const firstActionNode = data.workflow.nodes.find(n => n.stage === 0); + if (!firstActionNode) { + console.log("No Trigger node found!"); + await prismaClient.workflowExecution.update({ + where: { id: workflowExecutionId }, + data: { + status: "Failed", + completedAt: new Date(), + error: "No Trigger node found!" + } + }); + return; + } + queue.push({ + nodeId: firstActionNode.id, + inputData: data?.metadata + }); + } while (queue.length > 0) { const currentTask = queue.shift() - let currentInputData = currentTask?.inputData; + if (!currentTask || !currentTask.nodeId) continue; + + // Prevent infinite loops or multiple executions of the same node + if (executedNodeIds.has(currentTask.nodeId)) { + console.log(`Node ${currentTask.nodeId} already executed, skipping...`); + continue; + } + executedNodeIds.add(currentTask.nodeId); + + let currentInputData = currentTask.inputData; - const node = data.workflow.nodes.find(n => n.id === currentTask?.nodeId) + const node = data.workflow.nodes.find(n => n.id === currentTask.nodeId) if (!node) { console.log(`Failed to find node with ID ${currentTask?.nodeId}`); await prismaClient.workflowExecution.update({ @@ -257,6 +293,11 @@ export async function executeWorkflow( console.log(`[Interpolation] Before: ${JSON.stringify(interpolationContext)}`); // Resolve any {{variable}} references in the config console.log(`[nodeConfig] Before: ${JSON.stringify(nodeConfig)}`); + const itemsConfig = itemsToProcess.map(e => + resolveConfigVariables({ ...node.config as Record }, + interpolationContext, e?.sourceRefs + ) + ) nodeConfig = resolveConfigVariables(nodeConfig, interpolationContext, itemsToProcess[0]?.sourceRefs); console.log(`[Interpolation] After: ${JSON.stringify(nodeConfig)}`); @@ -288,8 +329,8 @@ export async function executeWorkflow( nodeId: node.id, userId: data.workflow.userId, credentialId: node.CredentialsID!, - config: nodeConfig, - items: itemsToProcess + config: itemsConfig.length > 0 ? itemsConfig : [nodeConfig], + items: itemsToProcess.length > 0 ? itemsToProcess : [{ json: {} }] } let execute: { success: boolean; output?: any; error?: string }; @@ -348,16 +389,22 @@ export async function executeWorkflow( ) for (const edge of outgoingEdges) { - const outputPinIndex = 0 //need to changes this after phase 5 (enables UI with 2 pins output per node) + // Resolve pin index from sourceHandle (e.g. "out-0", "out-1", "a-out" -> 0) + const match = edge.sourceHandle ? String(edge.sourceHandle).match(/\d+$/) : null; + const outputPinIndex = match ? parseInt(match[0], 10) : 0; - const branchData = execute.output?.[outputPinIndex] || []; + // Safe extraction from 2D Output Matrix ([wireIndex][rowIndex]) + const is2DMatrix = Array.isArray(execute.output) && Array.isArray(execute.output[0]); + const branchData = is2DMatrix + ? (execute.output[outputPinIndex] ?? execute.output[0] ?? []) + : (execute.output ?? []); queue.push({ nodeId: edge.target, inputData: branchData - }) + }); - console.log(`Pushed Node ${edge.target} into the queue!`); + console.log(`Pushed Node ${edge.target} with pin index ${outputPinIndex} into the queue!`); } } diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index de6008f..4854983 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -202,8 +202,8 @@ export const FilterNodeInput = z.object({ "existing_data_only", "group_by" ]), - sourceData: z.array(z.any()), - referenceData: z.array(z.any()).optional(), + sourceData: z.array(z.any()).or(z.string()), + referenceData: z.array(z.any()).or(z.string()).optional(), sourceKey: z.string().optional(), referenceKey: z.string().optional() }) diff --git a/packages/common/src/interpolation.ts b/packages/common/src/interpolation.ts index 1673052..64ebe9b 100644 --- a/packages/common/src/interpolation.ts +++ b/packages/common/src/interpolation.ts @@ -97,16 +97,69 @@ export function resolveVariable(variable: string, context: InterpolationContext, return `{{${variable}}}`; } + if (path === 'rows' || path === 'allRows') { + if (Array.isArray(nodeData) && Array.isArray(nodeData[0])) { + if (sourceRefs && sourceRefs[nodeName]) { + return nodeData[sourceRefs[nodeName].wireIndex]; + } + return nodeData[0] + } else if (Array.isArray(nodeData)) { + return nodeData + } else { + return nodeData.rows + } + } + + // 1. Relative execution mapping (Highest Priority during row-by-row workflow execution) if (sourceRefs && sourceRefs[nodeName]) { const { wireIndex, rowIndex } = sourceRefs[nodeName] const item = nodeData[wireIndex]?.[rowIndex]; - const value = getNestedValue(item?.json, path) + // Strip the wire index prefix if it exists (e.g. "[0].company_name" -> "company_name") + let relativePath = path; + const bracketPrefix = `[${wireIndex}].`; + const dotPrefix = `${wireIndex}.`; + + if (relativePath.startsWith(bracketPrefix)) { + relativePath = relativePath.slice(bracketPrefix.length); + } else if (relativePath.startsWith(dotPrefix)) { + relativePath = relativePath.slice(dotPrefix.length); + } + + const value = getNestedValue(item?.json, relativePath) - return value !== undefined ? value : `{{${variable}}}`; + if (value !== undefined) return value; } + // 2. Detect Explicit Absolute Coordinates (Fallback if relative mapping fails or is absent) + const isAbsoluteCoordinate = /^\d+/.test(path) || /^\[\d+\]/.test(path); + if (isAbsoluteCoordinate) { + // If the path looks like "[1].email" where we want a column from an array + const keys = path.replace(/\[(\d+)\]/g, '.$1').split('.').filter(Boolean); + + // First, try direct nested value fallback + const absoluteValue = getNestedValue(nodeData, path); + if (absoluteValue !== undefined) return absoluteValue; + + // Second, if they asked for a column from a specific wire (e.g. "[1].email"), + // extract it across the array! + if (keys.length === 2 && !isNaN(Number(keys[0]))) { + const wireIndex = Number(keys[0]); + const columnName = keys[1]; + const wireArray = nodeData[wireIndex]; + + if (Array.isArray(wireArray)) { + const mappedValues = wireArray.map(item => getNestedValue(item?.json, columnName!)); + // If it actually found values, return the array + if (mappedValues.some(v => v !== undefined)) { + return mappedValues; + } + } + } + } + + // // Column-based resolution: {{google_sheet.email}} → rows[currentRow][columnIndex] // if (nodeData.columns && nodeData.columns[path] !== undefined) { // const colIndex = nodeData.columns[path]; @@ -118,18 +171,18 @@ export function resolveVariable(variable: string, context: InterpolationContext, // // Fallback: standard nested path resolution (e.g., rows[1][0]) // const value = getNestedValue(nodeData, path); - // 1. Fallback for single-item nodes (e.g., Webhook or trigger nodes at wire 0, row 0) + // 3. Fallback for single-item nodes (e.g., Webhook or trigger nodes at wire 0, row 0) const fallbackItem = nodeData?.[0]?.[0]; if (fallbackItem && typeof fallbackItem === 'object' && 'json' in fallbackItem) { const value = getNestedValue(fallbackItem.json, path); if (value !== undefined) return value; } - // 2. Direct property fallback (if raw object was passed in context) + // 4. Direct property fallback (if raw object was passed in context) const directValue = getNestedValue(nodeData, path); if (directValue !== undefined) return directValue; - // 3. If nothing matched, preserve the raw {{variable}} placeholder + // 5. If nothing matched, preserve the raw {{variable}} placeholder return `{{${variable}}}`; @@ -295,6 +348,38 @@ export function extractVariablesFromOutput( return variables; } + if (Array.isArray(output) && Array.isArray(output[0])) { + + const sampleItem = output[0]?.[0] || output[1]?.[0]; + variables.push({ + name: "All Rows", + path: "rows", + type: "array", + sampleValue: output[0].length > 0 ? [`${output[0].length} items`] : "[]" + }) + if (sampleItem && typeof sampleItem === 'object' && 'json' in sampleItem && typeof sampleItem.json === 'object' && sampleItem.json !== null) { + const sampleData = sampleItem.json; + + for (const [key, value] of Object.entries(sampleData)) { + const name = key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, ' ') + const path = key + + const type = Array.isArray(value) ? 'array' : value === null || value === undefined ? "any" : typeof value + + let sampleValue = value + + variables.push({ + name, + path, + type, + sampleValue + }) + } + + } + return variables + } + // Special handling for spreadsheet-like data (rows with headers in first row) if (isSpreadsheetOutput(output)) { return extractSpreadsheetVariables(output); diff --git a/packages/nodes/src/filter/filter.executor.ts b/packages/nodes/src/filter/filter.executor.ts index c652b32..97bc885 100644 --- a/packages/nodes/src/filter/filter.executor.ts +++ b/packages/nodes/src/filter/filter.executor.ts @@ -6,11 +6,14 @@ import { FilterNodeInput } from "@repo/common/zod" export class FilterExecutor implements NodeExecutor { private getValueByPath(obj: any, path: string): any { - if (path === "__value__") return obj; + if (!obj || typeof obj !== 'object') return undefined; + const target = ('json' in obj && typeof obj.json === 'object' && obj.json !== null) ? obj.json : obj; + + if (path === "__value__") return obj; const parts = path.split('.'); - let current = obj; + let current = target; for (const part of parts) { if (current === null || current === undefined) return undefined; current = current[part]; @@ -25,8 +28,9 @@ export class FilterExecutor implements NodeExecutor { for (const item of sourceData) { let uniqueIdentifier = ""; + const rowData = (item && typeof item === 'object' && 'json' in item) ? item.json : item; if (sourceKey) { - uniqueIdentifier = this.normalizeValue(this.getValueByPath(item, sourceKey)) + uniqueIdentifier = this.normalizeValue(this.getValueByPath(rowData, sourceKey)) if (uniqueIdentifier === "[EMPTY]") { discardedData.push(item); @@ -34,7 +38,7 @@ export class FilterExecutor implements NodeExecutor { } } else { - uniqueIdentifier = this.deterministicStringify(item); + uniqueIdentifier = this.deterministicStringify(rowData); } if (seenValues.has(uniqueIdentifier)) { @@ -56,9 +60,12 @@ export class FilterExecutor implements NodeExecutor { private deterministicStringify(obj: any): string { if (typeof obj !== 'object' || obj === null) return String(obj); - const sortedKeys = Object.keys(obj).sort(); - const sortedArray = sortedKeys.map(key => [key, obj[key]]); + + const target = ('json' in obj && typeof obj.json === 'object' && obj.json !== null) ? obj.json : obj; + const sortedKeys = Object.keys(target).sort(); + const sortedArray = sortedKeys.map(key => [key, target[key]]); return JSON.stringify(sortedArray); + } private normalizeToObjects(data: any[]): any[] { @@ -144,7 +151,7 @@ export class FilterExecutor implements NodeExecutor { } async execute(context: ExecutionContext): Promise { try { - const parsed = FilterNodeInput.safeParse(context.config) + const parsed = FilterNodeInput.safeParse(context.config[0] || context.config) if (!parsed.success) { return { success: false, @@ -153,6 +160,27 @@ export class FilterExecutor implements NodeExecutor { } const { sourceData, referenceData, sourceKey, referenceKey, operation } = parsed.data; + if (typeof sourceData === 'string' && sourceData.trim().startsWith('{{')) { + return { + success: false, error: `Source data contains an unresolved variable (${sourceData}). Please test or run the upstream node first.` + } + } + if (!Array.isArray(sourceData)) { + return { + success: false, error: `Source data is not an array (received ${typeof sourceData}). Please verify upstream output.` + } + } + + if (typeof referenceData === 'string' && referenceData.trim().startsWith('{{')) { + return { + success: false, error: `Reference data contains an unresolved variable (${referenceData}). Please test or run the upstream node first.` + } + } + if (referenceData !== undefined && !Array.isArray(referenceData)) { + return { + success: false, error: `Reference data is not an array (received ${typeof referenceData}). Please verify upstream output.` + } + } const normalizedSource = this.normalizeToObjects(sourceData); const normalizedRef = referenceData ? this.normalizeToObjects(referenceData) : undefined @@ -203,24 +231,59 @@ export class FilterExecutor implements NodeExecutor { success: false, error: "sourceKey is required to group datasets" } - const groupResult = this.handleGroupBy(normalizedSource, sourceKey); - const wire0 = groupResult.groupArray.map(item => ({ json: item })) + const groupResult = this.handleGroupBy(normalizedSource, sourceKey) + const outputWires = groupResult.groupArray.map((group, wireIndex) => { + return (group.rows || []).map((row: any, rowIndex: number) => { + const json = row && row.json ? row.json : row; + const existingRefs = row && row.sourceRefs ? row.sourceRefs : {}; + return { + json: json, + sourceRefs: { + ...existingRefs, + [context.nodeId]: { wireIndex: wireIndex, rowIndex: rowIndex } + } + }; + }); + }); + return { success: true, - output: [wire0], + output: outputWires, metadata: { operation_used: operation, total_groups: groupResult.groupArray.length, items_processed: groupResult.total_processed, - items_without_key: groupResult.emptyCount + items_without_key: groupResult.emptyCount, + group_names: groupResult.groupArray.map(g => g.groupName) } } default: return { success: false, error: `Unknown operation: ${operation}` }; } - const wire0 = filteredData.map(item => ({ json: item })) - const wire1 = discardedData.map(item => ({ json: item })) + const wire0 = filteredData.map((item, index) => { + const json = (item && typeof item === 'object' && 'json' in item) ? item.json : item; + const sourceRefs = (item && typeof item === 'object' && 'sourceRefs' in item) ? item.sourceRefs : {}; + return { + json, + sourceRefs: { + ...sourceRefs, + [context.nodeId]: { wireIndex: 0, rowIndex: index } + } + }; + }); + + const wire1 = discardedData.map((item, index) => { + const json = (item && typeof item === 'object' && 'json' in item) ? item.json : item; + const sourceRefs = (item && typeof item === 'object' && 'sourceRefs' in item) ? item.sourceRefs : {}; + return { + json, + sourceRefs: { + ...sourceRefs, + [context.nodeId]: { wireIndex: 1, rowIndex: index } + } + }; + }); return { success: true, @@ -229,7 +292,8 @@ export class FilterExecutor implements NodeExecutor { metadata: { operation_used: operation, items_kept: filteredData.length, - items_discard: discardedData.length + items_discard: discardedData.length, + group_names: ['Kept Data', 'Discarded Data'] } } } diff --git a/packages/nodes/src/filter/filter.node.ts b/packages/nodes/src/filter/filter.node.ts index 06645ba..d7a8722 100644 --- a/packages/nodes/src/filter/filter.node.ts +++ b/packages/nodes/src/filter/filter.node.ts @@ -9,7 +9,38 @@ export class FilterNode { type: "filter", description: "Filtering and data processing node", config: { - + operations: [ + { + value: "unique_rows", + label: "Remove Duplicates", + outputs: [ + { id: "out-0", label: "Unique" }, + { id: "out-1", label: "Duplicates" } + ] + }, + { + value: "new_data_only", + label: "New Data Only", + outputs: [ + { id: "out-0", label: "New Data" }, + { id: "out-1", label: "Existing Data" } + ] + }, { + value: "existing_data_only", + label: "Existing Data Only", + outputs: [ + { id: "out-0", label: "Existing Data" }, + { id: "out-1", label: "New Data" } + ] + }, + { + value: "group_by", + label: "Group Data By Key", + outputs: [ + { id: "out-0", label: "Grouped Data" } + ] + }, + ] }, requireAuth: false, icon: "/filtering.png" diff --git a/packages/nodes/src/gmail/gmail.executor.ts b/packages/nodes/src/gmail/gmail.executor.ts index b81574c..7edbe12 100644 --- a/packages/nodes/src/gmail/gmail.executor.ts +++ b/packages/nodes/src/gmail/gmail.executor.ts @@ -6,7 +6,7 @@ interface NodeExecutionContext { nodeId: string; credentialId: string; userId: string; - config?: any; + config: any[]; authType?: string; items: ExecuteItem[] } @@ -58,12 +58,15 @@ class GmailExecutor { } // Send email - const { to, subject, body } = context.config; const outputBoxes: ExecuteItem[] = []; - for (const item of context.items) { - + const itemsToProcess: ExecuteItem[] = context.items && context.items.length > 0 ? context.items : [{ json: {} }] + for (let index = 0; index < itemsToProcess.length; index++) { + const configForThisRow = context.config?.[index] || {}; + const { to, subject, body } = configForThisRow; + const item = itemsToProcess[index] + if (!item) continue const result = await this.gmailService.sendEmail(to, subject, body); if (!result.success) { @@ -72,14 +75,17 @@ class GmailExecutor { outputBoxes.push({ json: { - ...item.json, + ...(item.json || {}), gmailResponse: { status: "sent", messageId: result.data?.id, threadId: result.data?.threadId } }, - sourceRefs: item.sourceRefs + sourceRefs: { + ...(item.sourceRefs || {}), + [context.nodeId]: { wireIndex: 0, rowIndex: index } + } }) } diff --git a/packages/nodes/src/google-sheets/google-sheets.executor.ts b/packages/nodes/src/google-sheets/google-sheets.executor.ts index 1674160..ae7c72f 100644 --- a/packages/nodes/src/google-sheets/google-sheets.executor.ts +++ b/packages/nodes/src/google-sheets/google-sheets.executor.ts @@ -11,7 +11,7 @@ interface SheetAuthContext { } interface NodeExecutionContext extends SheetAuthContext { nodeId: string, - config: any, //sheet id / range... + config: any[], //sheet id / range... items: ExecuteItem[] } @@ -141,7 +141,7 @@ class GoogleSheetsNodeExecutor { } - const operation = context.config.operation; + const operation = context.config[0]?.operation; console.log("operation from sheet executor: ", operation) switch (operation) { case 'read_rows': @@ -210,35 +210,62 @@ class GoogleSheetsNodeExecutor { } private async prepareValuesForSheet(context: NodeExecutionContext): Promise { - const mode = context.config.mappingMode || 'visual'; + const mode = context.config[0]?.mappingMode || 'visual'; if (mode === 'bulk') { - const rawValues = context.config.bulkValues || context.items.map(item => item.json); + const rawValues = context.config[0]?.bulkValues || context.items.map(item => item.json); return this.normalizeValues(rawValues) } - const mappedColumns = context.config.mappedColumns || {}; - const headerResult = await this.getHeaderRow( context, - context.config.spreadsheetId, - context.config.sheetName + context.config[0]?.spreadsheetId, + context.config[0]?.sheetName ); if (!headerResult.success) { throw new Error(`Could not fetch headers for mapping: ${headerResult.error}`); } const headers = headerResult.output as string[]; - // If the sheet has no headers, fallback to just dumping the object values if (!headers || headers.length === 0) { - return [Object.values(mappedColumns)]; + return [Object.values(context.config[0]?.mappedColumns || {})]; } - const finalRow = headers.map(headerName => { - return mappedColumns[headerName] !== undefined ? mappedColumns[headerName] : "" - }) + const allRows: any[][] = []; + const itemsConfig = context.config; + + for (const config of itemsConfig) { + const mappedColumns = config.mappedColumns || {}; + + // Detect if any mapped column is an Array + const isBulkArrayMapping = Object.values(mappedColumns).some(val => Array.isArray(val)); + + if (isBulkArrayMapping) { + // Find max array length to determine how many rows to build + let maxRows = 1; + for (const val of Object.values(mappedColumns)) { + if (Array.isArray(val) && val.length > maxRows) maxRows = val.length; + } + + // Pivot the arrays into multiple rows + for (let i = 0; i < maxRows; i++) { + const row = headers.map(headerName => { + const val = mappedColumns[headerName]; + if (Array.isArray(val)) return val[i] !== undefined ? val[i] : ""; + return val !== undefined ? val : ""; // Duplicate standard strings + }); + allRows.push(row); + } + } else { + // Standard single row mapping + const row = headers.map(headerName => { + return mappedColumns[headerName] !== undefined ? mappedColumns[headerName] : "" + }); + allRows.push(row); + } + } - return [finalRow] + return allRows; } /** * Checks if user's range starts from row 1 (includes headers) @@ -266,9 +293,9 @@ class GoogleSheetsNodeExecutor { async executeReadRows(sheetsService: GoogleSheetsService, context: NodeExecutionContext): Promise { try { - const spreadsheetId = context.config.spreadsheetId; - const sheetName = context.config.sheetName; - const userRange = (!context.config.fetchEntireTable && context.config.range) ? context.config.range : 'A1:Z' + const spreadsheetId = context.config[0]?.spreadsheetId; + const sheetName = context.config[0]?.sheetName; + const userRange = (!context.config[0]?.fetchEntireTable && context.config[0]?.range) ? context.config[0]?.range : 'A1:Z' let combinedRows: any[]; let dataRowCount: number; @@ -401,8 +428,8 @@ class GoogleSheetsNodeExecutor { async executeWriteRows(sheetService: GoogleSheetsService, context: NodeExecutionContext): Promise { try { - const spreadsheetId = context.config.spreadsheetId; - const range = context.config.range; + const spreadsheetId = context.config[0]?.spreadsheetId; + const range = context.config[0]?.range; const values = await this.prepareValuesForSheet(context); if (!range) { @@ -413,11 +440,12 @@ class GoogleSheetsNodeExecutor { } const response = await sheetService.writeRows({ spreadsheetId: spreadsheetId, - range: `${context.config.sheetName}!${range}`, + range: `${context.config[0]?.sheetName}!${range}`, values: values }) + const itemsToMap: ExecuteItem[] = context.items.length > 0 ? context.items : [{ json: context.config[0]?.mappedColumns || {} }]; - const outputBoxes = context.items.map(item => { + const outputBoxes = itemsToMap.map((item, index) => { return { json: { ...item.json, @@ -426,7 +454,10 @@ class GoogleSheetsNodeExecutor { rowsUpdated: response.updatedRows || 1, } }, - sourceRefs: item.sourceRefs + sourceRefs: { + ...(item.sourceRefs), + [context.nodeId]: { wireIndex: 0, rowIndex: index } + } } }) return { @@ -443,8 +474,8 @@ class GoogleSheetsNodeExecutor { async executeAppendRows(sheetService: GoogleSheetsService, context: NodeExecutionContext): Promise { try { - const spreadsheetId = context.config.spreadsheetId; - const range = context.config.sheetName; + const spreadsheetId = context.config[0]?.spreadsheetId; + const range = context.config[0]?.sheetName; const values = await this.prepareValuesForSheet(context); @@ -454,7 +485,8 @@ class GoogleSheetsNodeExecutor { values: values }) console.log(`append rows: ${response}`) - const outputBoxes = context.items.map(item => { + const itemsToMap: ExecuteItem[] = context.items.length > 0 ? context.items : [{ json: context.config[0]?.mappedColumns || {} }]; + const outputBoxes = itemsToMap.map((item, index) => { return { json: { ...item.json, @@ -463,7 +495,10 @@ class GoogleSheetsNodeExecutor { rowUpdated: response.updates.updatedRange || response.tableRange } }, - sourceRefs: item.sourceRefs + sourceRefs: { + ...(item.sourceRefs || {}), + [context.nodeId]: { wireIndex: 0, rowIndex: index } + } } }) return { @@ -481,14 +516,16 @@ class GoogleSheetsNodeExecutor { async executeClearRows(sheetService: GoogleSheetsService, context: NodeExecutionContext): Promise { try { - const spreadsheetId = context.config.spreadsheetId; - const range = (!context.config.clearEntireTable && context.config.range) ? `${context.config.sheetName}!${context.config.range}` : (context.config.includeHeaderRow ? `${context.config.sheetName}!A1:Z` : `${context.config.sheetName}!A2:Z`); + const spreadsheetId = context.config[0]?.spreadsheetId; + const range = (!context.config[0]?.clearEntireTable && context.config[0]?.range) ? `${context.config[0]?.sheetName}!${context.config[0]?.range}` : (context.config[0]?.includeHeaderRow ? `${context.config[0]?.sheetName}!A1:Z` : `${context.config[0]?.sheetName}!A2:Z`); const response = await sheetService.clearRows({ spreadsheetId: spreadsheetId, range: range }) - const outputBoxes = context.items.map(item => { + const itemsToMap: ExecuteItem[] = context.items.length > 0 ? context.items : [{ json: context.config[0]?.mappedColumns || {} }]; + + const outputBoxes = itemsToMap.map((item, index) => { return { json: { ...item.json, @@ -497,7 +534,10 @@ class GoogleSheetsNodeExecutor { clearedRange: response.clearedRange } }, - sourceRefs: item.sourceRefs + sourceRefs: { + ...(item.sourceRefs), + [context.nodeId]: { wireIndex: 0, rowIndex: index } + } } }) diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts index f015f3a..11f73fa 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -13,5 +13,5 @@ export { GoogleOAuthService } from './common/google-oauth-service.js'; export { default as NodeRegistry } from './registry/node-registry.js'; export { ExecutionRegister } from './registry/execution.registory.js'; export { GoogleSheetsService } from './google-sheets/google-sheets.service.js'; - +export { FilterNode } from './filter/filter.node.js'; console.log("Hello World From node / index.ts"); diff --git a/packages/nodes/src/registry/Execution.config.types.ts b/packages/nodes/src/registry/Execution.config.types.ts index deb0f1b..c8cdc6d 100644 --- a/packages/nodes/src/registry/Execution.config.types.ts +++ b/packages/nodes/src/registry/Execution.config.types.ts @@ -4,7 +4,7 @@ export interface ExecutionContext { nodeId: string; userId: string; credentialId?: string; - config: Record; + config: any[]; items: ExecuteItem[] } export interface ExecutionResult { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b9a3ea..a50ef1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -191,6 +191,9 @@ importers: react-redux: specifier: ^9.2.0 version: 9.2.0(@types/react@19.1.9)(react@19.1.1)(redux@5.0.1) + react-resizable-panels: + specifier: ^4.14.1 + version: 4.14.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) recharts: specifier: ^2.15.1 version: 2.15.4(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -2180,6 +2183,7 @@ packages: eslint@9.32.0: resolution: {integrity: sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -3450,6 +3454,12 @@ packages: '@types/react': optional: true + react-resizable-panels@4.14.1: + resolution: {integrity: sha512-OB1bXDNTLcGgTTbaX6Dn5efZhlMboSnkfr1w4xpsJTHPEbRJpACBhXpgRCXozgPO1ztgFeZRUe5J3mFNCVKm5g==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + react-smooth@4.0.4: resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==} peerDependencies: @@ -7369,6 +7379,11 @@ snapshots: optionalDependencies: '@types/react': 19.1.9 + react-resizable-panels@4.14.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1): + dependencies: + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + react-smooth@4.0.4(react-dom@19.1.1(react@19.1.1))(react@19.1.1): dependencies: fast-equals: 5.4.1