diff --git a/apps/http-backend/src/routes/userRoutes/executionRoutes.ts b/apps/http-backend/src/routes/userRoutes/executionRoutes.ts index c2c77ad..df43c15 100644 --- a/apps/http-backend/src/routes/userRoutes/executionRoutes.ts +++ b/apps/http-backend/src/routes/userRoutes/executionRoutes.ts @@ -30,12 +30,14 @@ execRouter.post('/node', userMiddleware, async(req: AuthRequest, res: Response) const type = nodeData.AvailableNode.type const config = dataSafe.data.Config ? dataSafe.data.Config : nodeData.config // for test api data prefered fist then config in db console.log(`config and type: ${JSON.stringify(config)} & ${type}`) - // if(nodeData.CredentialsID) + const context = { userId: req.user.sub, - config: config , - // credentialsId: nodeData.CredentialsID || "" + config: config, + credentialId: nodeData.CredentialsID || config?.credentialId || "" } + + console.log(`Execution context: ${JSON.stringify(context)}`) const executionResult = await ExecutionRegister.execute(type, context) console.log(`Execution result: ${executionResult}`) diff --git a/apps/web/app/components/ui/variable-panel.tsx b/apps/web/app/components/ui/variable-panel.tsx new file mode 100644 index 0000000..149f538 --- /dev/null +++ b/apps/web/app/components/ui/variable-panel.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { PreviousNodeOutput, VariableDefinition } from "@/app/lib/types/node.types"; +import { useAppSelector } from "@/app/hooks/redux"; +import { selectAllOutputs, NodeTestOutput } from "@/store/slices/nodeOutputSlice"; + +interface VariablePanelProps { + previousNodes: PreviousNodeOutput[]; + onInsert: (variableSyntax: string) => void; + activeField: string | null; +} + +export function VariablePanel({ previousNodes, onInsert, activeField }: VariablePanelProps) { + // Get tested outputs from Redux + const testedOutputs = useAppSelector(selectAllOutputs); + + // Debug logging + console.log('[VariablePanel] previousNodes:', previousNodes); + console.log('[VariablePanel] testedOutputs from Redux:', testedOutputs); + + // Merge static outputSchema with dynamic tested data + const enrichedNodes: PreviousNodeOutput[] = previousNodes.map(node => { + const testedData = testedOutputs[node.nodeId]; + + console.log(`[VariablePanel] Node ${node.nodeName} (${node.nodeId}):`, { + hasTestedData: !!testedData, + testedSuccess: testedData?.success, + testedVariablesCount: testedData?.variables?.length, + staticVariablesCount: node.variables?.length + }); + + // If node was tested, use the dynamic variables from real output + if (testedData?.success && testedData.variables.length > 0) { + console.log(`[VariablePanel] Using DYNAMIC variables for ${node.nodeName}:`, testedData.variables); + return { + ...node, + variables: testedData.variables, + // Mark as dynamically discovered + _tested: true + } as PreviousNodeOutput & { _tested?: boolean }; + } + + // Otherwise use the static outputSchema + console.log(`[VariablePanel] Using STATIC variables for ${node.nodeName}:`, node.variables); + return node; + }); + + const handleClick = (nodeName: string, variable: VariableDefinition) => { + // Build the variable syntax: {{nodeName.path}} + const syntax = `{{${nodeName}.${variable.path}}}`; + onInsert(syntax); + }; + + if (enrichedNodes.length === 0) { + return ( +
+

No previous nodes available.

+

Add a trigger or action before this node to use its output data.

+
+ ); + } + + return ( +
+
+ ๐Ÿ“ฆ Available Variables +
+ + {!activeField && ( +
+ Click on a field first, then click a variable to insert it. +
+ )} + + {enrichedNodes.map((node) => { + const isTested = testedOutputs[node.nodeId]?.success; + + return ( +
+ {/* Node Header */} +
+
+ {node.icon || "โš™๏ธ"} + {node.nodeName} +
+ {isTested && ( + + โœ“ Tested + + )} +
+ + {/* No variables message */} + {node.variables.length === 0 && ( +

+ Test this node to discover available variables +

+ )} + + {/* Variable Pills */} +
+ {node.variables.map((variable) => ( + + ))} +
+ + {/* Render nested children if any */} + {node.variables + .filter(v => v.children && v.children.length > 0) + .map(variable => ( +
+
โ”” {variable.name} fields:
+
+ {variable.children?.map(child => ( + + ))} +
+
+ ))} +
+ ); + })} +
+ ); +} + +// Helper function for type icons +function getTypeIcon(type: string): string { + const icons: Record = { + string: "๐Ÿ“", + number: "๐Ÿ”ข", + boolean: "โœ“", + date: "๐Ÿ“…", + array: "๐Ÿ“‹", + object: "{}", + any: "โ€ข", + }; + return icons[type] || "โ€ข"; +} + +export default VariablePanel; diff --git a/apps/web/app/lib/api.ts b/apps/web/app/lib/api.ts index a47ead2..a66c212 100644 --- a/apps/web/app/lib/api.ts +++ b/apps/web/app/lib/api.ts @@ -117,5 +117,27 @@ export const api = { name: tab.name })) }, + }, + execute: { + // Execute a single node for testing + node: async (nodeId: string, config?: any) => { + const res = await axios.post(`${BACKEND_URL}/execute/node`, + { NodeId: nodeId, Config: config }, + { + withCredentials: true, + headers: { "Content-Type": "application/json" }, + } + ); + console.log('[api.execute.node] Response:', res); + + // Check if execution was successful + if (res.data?.data?.success) { + return res.data.data.output; + } + + // If not successful, throw error with message + const errorMessage = res.data?.data?.error || res.data?.message || "Execution failed"; + throw new Error(errorMessage); + } } }; diff --git a/apps/web/app/lib/nodeConfigs/gmail.action.ts b/apps/web/app/lib/nodeConfigs/gmail.action.ts index 57c37a2..c9e2fe1 100644 --- a/apps/web/app/lib/nodeConfigs/gmail.action.ts +++ b/apps/web/app/lib/nodeConfigs/gmail.action.ts @@ -46,5 +46,11 @@ export const gmailActionConfig: NodeConfig = { ], summary: "Send emails via Gmail", // โœ… Correct description - helpUrl: "https://docs.example.com/gmail-action" + helpUrl: "https://docs.example.com/gmail-action", + + outputSchema: [ + { name: "Message ID", path: "messageId", type: "string" }, + { name: "Status", path: "status", type: "string" }, + { name: "Thread ID", path: "threadId", type: "string" }, + ], }; diff --git a/apps/web/app/lib/nodeConfigs/googleSheet.action.ts b/apps/web/app/lib/nodeConfigs/googleSheet.action.ts index b4ca12e..4b5674f 100644 --- a/apps/web/app/lib/nodeConfigs/googleSheet.action.ts +++ b/apps/web/app/lib/nodeConfigs/googleSheet.action.ts @@ -56,5 +56,20 @@ export const googleSheetActionConfig: NodeConfig = { ], summary: "Interact with Google Sheets spreadsheets", - helpUrl: "https://docs.example.com/google-sheets-action" + helpUrl: "https://docs.example.com/google-sheets-action", + + outputSchema: [ + { + name: "Rows", + path: "rows", + type: "array", + description: "All rows from the sheet", + children: [ + { name: "Row Index", path: "[*].index", type: "number" }, + // Dynamic columns added at runtime based on sheet headers + ] + }, + { name: "Row Count", path: "rowCount", type: "number" }, + { name: "Sheet Name", path: "sheetName", type: "string" }, + ], }; diff --git a/apps/web/app/lib/types/node.types.ts b/apps/web/app/lib/types/node.types.ts index 49f4b9d..f90b8a3 100644 --- a/apps/web/app/lib/types/node.types.ts +++ b/apps/web/app/lib/types/node.types.ts @@ -1,5 +1,23 @@ // What information does a node config need? +export type VariableType = 'string' | 'number' | 'boolean' | 'date' | 'array' | 'object' | 'any'; + +export interface VariableDefinition { + name: string; // "Email Address" + path: string; // "rows[0].email" + type: VariableType; + description?: string; + sampleValue?: any; // "john@example.com" + children?: VariableDefinition[]; // For nested objects/arrays +} +// What gets passed to VariablePanel +export interface PreviousNodeOutput { + nodeId: string; // "node_abc123" + nodeName: string; // "Google Sheets" + nodeType: string; // "google_sheet" + icon?: string; // "๐Ÿ“Š" + variables: VariableDefinition[]; +} export interface NodeConfig { id: string; // Unique identifier for the node, e.g., "google_sheet" type: "trigger" | "action"; // Node category @@ -19,6 +37,10 @@ export interface NodeConfig { tags?: string[]; // Searchable tags // Any extra raw config data data?: Record; // Allow extra arbitrary config as needed + // NEW: What this node outputs (for next nodes to use) + outputSchema?: VariableDefinition[]; + // NEW: Sample output for UI preview + sampleOutput?: Record; } export interface ConfigField { @@ -37,4 +59,8 @@ export interface ConfigField { min?: number; // For number fields: min value max?: number; // For number fields: max value // You could add validation, inputMask, or other metadata as needed here + // NEW: Allow variable insertion + acceptsVariables?: boolean; // default true for text/textarea + // NEW: Restrict which variable types can be dropped + acceptTypes?: VariableType[]; } \ No newline at end of file diff --git a/apps/web/app/workflows/[id]/components/ConfigModal.tsx b/apps/web/app/workflows/[id]/components/ConfigModal.tsx index 35ca7d3..acd7558 100644 --- a/apps/web/app/workflows/[id]/components/ConfigModal.tsx +++ b/apps/web/app/workflows/[id]/components/ConfigModal.tsx @@ -2,10 +2,21 @@ import { getNodeConfig } from "@/app/lib/nodeConfigs"; import { useEffect, useState } from "react"; import { HOOKS_URL } from "@repo/common/zod"; -import { useAppSelector } from "@/app/hooks/redux"; +import { extractVariablesFromOutput, resolveConfigVariables, InterpolationContext } from "@repo/common/zod"; +import { useAppSelector, useAppDispatch } from "@/app/hooks/redux"; import { toast } from "sonner"; import { useCredentials } from "@/app/hooks/useCredential"; import { api } from "@/app/lib/api"; +import { PreviousNodeOutput, VariableDefinition } from "@/app/lib/types/node.types"; +import { VariablePanel } from "@/app/components/ui/variable-panel"; +import { + setNodeOutput, + setNodeLoading, + selectNodeOutput, + selectNodeLoading, + selectAllOutputs, + NodeTestOutput +} from "@/store/slices/nodeOutputSlice"; interface ConfigModalProps { isOpen: boolean; @@ -13,6 +24,7 @@ interface ConfigModalProps { onClose: () => void; onSave: (selectedNode: string, config: any, userId: string) => Promise; workflowId?: string; + previousNodes: PreviousNodeOutput[]; } export default function ConfigModal({ @@ -21,38 +33,162 @@ export default function ConfigModal({ onClose, onSave, workflowId, + previousNodes }: ConfigModalProps) { const [config, setConfig] = useState>({}); const [dynamicOptions, setDynamicOptions] = useState>({}); const [loading, setLoading] = useState(false); + const [activeField, setActiveField] = useState(null); + const [testResult, setTestResult] = useState(null); + + const dispatch = useAppDispatch(); const userId = useAppSelector((state) => state.user.userId) as string; + + // Get all tested outputs from Redux (for variable resolution) + const allTestedOutputs = useAppSelector(selectAllOutputs); + + // Get test output from Redux for this node + const nodeTestOutput = useAppSelector((state) => + selectedNode ? selectNodeOutput(state, selectedNode.id) : undefined + ); + const isTestingNode = useAppSelector((state) => + selectedNode ? selectNodeLoading(state, selectedNode.id) : false + ); const fetchOptionsMap: Record Promise> = { "google.getDocuments" : ({credentialId}) => api.google.getDocuments(credentialId), "google.getSheets" : ({spreadsheetId, credentialId}) => api.google.getSheets(spreadsheetId, credentialId) } - - const handleFieldChange = async (fieldName: string, value: string, nodeConfig: any) => { - // Update config with new value - const updatedConfig = ({ ...config, [fieldName]: value }) - console.log(fieldName, " ", value, " ", nodeConfig) - console.log(config, "from handle field function - 1") - setConfig((prev) => ({ ...prev, [fieldName]: value })); - console.log(config, "from handle field fun - 2") - console.log({ ...config, [fieldName]: value }, "what we're setting") - // Find fields that depend on this field - const dependentFields = nodeConfig.fields.filter((f:any) => f.dependsOn === fieldName); - for (const depField of dependentFields) { - const fetchFn = depField.fetchOptions ? fetchOptionsMap[depField.fetchOptions] : undefined; - console.log(fetchFn, "fecth FN") - if (fetchFn) { - const options = await fetchFn(updatedConfig); - // console.log(({ ...config, [depField.name]: options }), "optiops setting") - setDynamicOptions((prev) => ({ ...prev, [depField.name]: options })); + // Build interpolation context from all previously tested nodes + const buildTestContext = (): InterpolationContext => { + const context: InterpolationContext = {}; + console.log('[buildTestContext] All tested outputs:', allTestedOutputs); + + for (const [nodeId, testOutput] of Object.entries(allTestedOutputs)) { + console.log(`[buildTestContext] Processing node ${nodeId}:`, { + nodeName: testOutput.nodeName, + success: testOutput.success, + hasData: !!testOutput.data + }); + + if (testOutput.success && testOutput.data) { + // Normalize node name: "Google Sheet" -> "google_sheet" + const normalizedName = testOutput.nodeName.toLowerCase().replace(/\s+/g, '_'); + console.log(`[buildTestContext] Normalized "${testOutput.nodeName}" -> "${normalizedName}"`); + context[normalizedName] = testOutput.data; + } + } + + console.log('[buildTestContext] Final context:', context); + return context; + }; + + // Test the current node and store output in Redux + const handleTestNode = async () => { + if (!selectedNode) return; + + console.log('[ConfigModal] Testing node:', selectedNode.id, selectedNode.name); + dispatch(setNodeLoading({ nodeId: selectedNode.id, loading: true })); + + try { + // Build context from previously tested nodes for variable resolution + const interpolationContext = buildTestContext(); + console.log('[ConfigModal] Interpolation context:', interpolationContext); + + // Resolve any {{variable}} in the config before testing + const resolvedConfig = resolveConfigVariables(config, interpolationContext); + console.log('[ConfigModal] Original config:', config); + console.log('[ConfigModal] Resolved config:', resolvedConfig); + + // Check if any variables couldn't be resolved + const unresolvedVars = Object.entries(resolvedConfig) + .filter(([_, value]) => typeof value === 'string' && value.includes('{{')) + .map(([key, value]) => `${key}: ${value}`); + + if (unresolvedVars.length > 0) { + toast.warning(`Some variables couldn't be resolved. Test the previous nodes first.\n${unresolvedVars.join('\n')}`); + } + + const response = await api.execute.node(selectedNode.id, resolvedConfig); + console.log('[ConfigModal] API response (already extracted output):', response); + + // api.execute.node already returns res.data.data.output directly + const outputData = response; + console.log('[ConfigModal] Output data for extraction:', outputData); + + // Extract variables from the output for the variable panel + const extractedVariables = extractVariablesFromOutput(outputData); + console.log('[ConfigModal] Extracted variables:', extractedVariables); + + // Convert to VariableDefinition format + const variables: VariableDefinition[] = extractedVariables.map(v => ({ + name: v.name, + path: v.path, + type: v.type as any, + sampleValue: v.sampleValue + })); + + // Store in Redux + const testOutput: NodeTestOutput = { + nodeId: selectedNode.id, + nodeName: selectedNode.name || selectedNode.data?.label || 'Node', + nodeType: selectedNode.type || '', + data: outputData, + variables, + testedAt: Date.now(), + success: true + }; + + dispatch(setNodeOutput(testOutput)); + setTestResult(outputData); + toast.success("Node test successful!"); + } catch (error: any) { + const errorMessage = error.response?.data?.message || error.message || "Test failed"; + + dispatch(setNodeOutput({ + nodeId: selectedNode.id, + nodeName: selectedNode.name || 'Node', + nodeType: selectedNode.type || '', + data: null, + variables: [], + testedAt: Date.now(), + success: false, + error: errorMessage + })); + + toast.error(`Test failed: ${errorMessage}`); } + }; + + const handleVariableInsert = (variableSyntax: string)=> { + if(!activeField) return; + + const currentValue = config[activeField] || ""; + setConfig({...config, [activeField]: currentValue + variableSyntax}) } -}; + + const handleFieldChange = async (fieldName: string, value: string, nodeConfig: any) => { + // Update config with new value + const updatedConfig = ({ ...config, [fieldName]: value }) + console.log(fieldName, " ", value, " ", nodeConfig) + console.log(config, "from handle field function - 1") + setConfig((prev) => ({ ...prev, [fieldName]: value })); + console.log(config, "from handle field fun - 2") + console.log({ ...config, [fieldName]: value }, "what we're setting") + // Find fields that depend on this field + const dependentFields = nodeConfig.fields.filter((f:any) => f.dependsOn === fieldName); + + for (const depField of dependentFields) { + const fetchFn = depField.fetchOptions ? fetchOptionsMap[depField.fetchOptions] : undefined; + console.log(fetchFn, "fecth FN") + if (fetchFn) { + const options = await fetchFn(updatedConfig); + // console.log(({ ...config, [depField.name]: options }), "optiops setting") + setDynamicOptions((prev) => ({ ...prev, [depField.name]: options })); + } + } + }; // console.log("This is the credential Data from config from backend" , config); // Fetch credentials with hook based on node config (google, etc) if appropriate let credType: string | null = null; @@ -97,6 +233,7 @@ export default function ConfigModal({ <> setActiveField(field.name)} onChange={async(e) => { console.log("log for options: ",fieldValue) await handleFieldChange(field.name, e.target.value, nodeConfig); @@ -192,6 +330,7 @@ export default function ConfigModal({