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({
<>