From f302c7af8177693f07304cec5513f0c172e9d0a9 Mon Sep 17 00:00:00 2001 From: Tejabudumuru3 Date: Mon, 3 Aug 2026 00:00:02 +0530 Subject: [PATCH] feat: implemente node configuration registry, execution engine, and dynamic config modal for Gmail, Google Sheets, and FILTER nodes --- apps/web/app/components/nodes/BaseNode.tsx | 26 +-- .../app/components/ui/RichVariableInput.tsx | 102 +++++++++ apps/web/app/components/ui/TestPanel.tsx | 60 +++--- apps/web/app/components/ui/variable-panel.tsx | 27 ++- apps/web/app/lib/nodeConfigs/filter.action.ts | 70 +++++++ apps/web/app/lib/nodeConfigs/index.ts | 3 + apps/web/app/lib/types/node.types.ts | 2 +- .../workflows/[id]/components/ConfigModal.tsx | 113 +++++++++- apps/web/public/filtering.png | Bin 0 -> 31889 bytes apps/web/public/gmail.png | Bin 41701 -> 0 bytes apps/web/public/gmail.svg | 14 ++ apps/web/public/google_sheet.png | Bin 24789 -> 0 bytes apps/web/public/google_sheet.svg | 55 +++++ apps/web/public/webhook.png | Bin 71499 -> 0 bytes apps/web/public/webhook.svg | 1 + packages/common/src/index.ts | 33 ++- packages/common/src/interpolation.ts | 75 ++++--- packages/nodes/package.json | 5 +- packages/nodes/src/filter/filter.executor.ts | 198 ++++++++++++++++++ packages/nodes/src/filter/filter.node.ts | 26 +++ packages/nodes/src/gmail/gmail.node.ts | 5 +- .../src/google-sheets/google-sheets.node.ts | 5 +- .../nodes/src/registry/execution.registory.ts | 4 +- packages/nodes/src/registry/node-registry.ts | 3 +- 24 files changed, 716 insertions(+), 111 deletions(-) create mode 100644 apps/web/app/components/ui/RichVariableInput.tsx create mode 100644 apps/web/app/lib/nodeConfigs/filter.action.ts create mode 100644 apps/web/public/filtering.png delete mode 100644 apps/web/public/gmail.png create mode 100644 apps/web/public/gmail.svg delete mode 100644 apps/web/public/google_sheet.png create mode 100644 apps/web/public/google_sheet.svg delete mode 100644 apps/web/public/webhook.png create mode 100644 apps/web/public/webhook.svg create mode 100644 packages/nodes/src/filter/filter.executor.ts create mode 100644 packages/nodes/src/filter/filter.node.ts diff --git a/apps/web/app/components/nodes/BaseNode.tsx b/apps/web/app/components/nodes/BaseNode.tsx index 8cf1869..a7e34ea 100644 --- a/apps/web/app/components/nodes/BaseNode.tsx +++ b/apps/web/app/components/nodes/BaseNode.tsx @@ -94,23 +94,23 @@ export default function BaseNode({ id, type, data }: BaseNodeProps) { {/* Icon + Label */}
- { icon ? - : ("⚡")} + {icon ? + : ("⚡")} {label}
- {data.isConfigured ? ( - - ✓ Configured - - ) : ( - - Not Configured - - )} + {data.isConfigured ? ( + + ✓ Configured + + ) : ( + + Not Configured + + )}
{/* Buttons */} @@ -120,7 +120,7 @@ export default function BaseNode({ id, type, data }: BaseNodeProps) { onClick={onConfigure} className="text-xs px-2 py-1 bg-blue-100 rounded hover:bg-blue-200" > - file_type_config + file_type_config )} {onTest && ( diff --git a/apps/web/app/components/ui/RichVariableInput.tsx b/apps/web/app/components/ui/RichVariableInput.tsx new file mode 100644 index 0000000..b3ec8e7 --- /dev/null +++ b/apps/web/app/components/ui/RichVariableInput.tsx @@ -0,0 +1,102 @@ +"use client"; +import React, { useRef, useEffect } from 'react'; + +export interface AvailableNode { + id: string; + name: string; +} + +interface RichVariableInputProps { + value: string; + onChange: (newValue: string) => void; + availableNodes: AvailableNode[]; + placeholder?: string; + onFocus?: () => void; +} + +const COLORS = [ + "bg-blue-500/20 text-blue-300 border-blue-500/50", + "bg-green-500/20 text-green-300 border-green-500/50", + "bg-purple-500/20 text-purple-300 border-purple-500/50", + "bg-orange-500/20 text-orange-300 border-orange-500/50", + "bg-pink-500/20 text-pink-300 border-pink-500/50", + "bg-teal-500/20 text-teal-300 border-teal-500/50", +]; + +export function getNodeColorClass(nodeId: string): string { + if (!nodeId) return COLORS[0]!; + let hash = 0; + for (let i = 0; i < nodeId.length; i++) { + hash = nodeId.charCodeAt(i) + ((hash << 5) - hash); + } + const index = Math.abs(hash) % COLORS.length; + return COLORS[index]!; +} + +export function parseValueToHtml(rawValue: string, availableNodes: AvailableNode[]): string { + if (!rawValue) return ""; + + return rawValue.replace(/\{\{([^.]+)\.([^}]+)\}\}/g, (match, nodeId, path) => { + const node = availableNodes.find(n => n.id === nodeId); + const displayName = node ? node.name : "Unknown Node"; + const colorClass = getNodeColorClass(nodeId); + + return `${displayName} > ${path}`; + }); +} + +export function RichVariableInput({ value, onChange, availableNodes, placeholder, onFocus }: RichVariableInputProps) { + 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; + + let rawString = ""; + + // Iterate through the DOM children to reconstruct the string + editorRef.current.childNodes.forEach((node) => { + if (node.nodeType === Node.TEXT_NODE) { + rawString += node.textContent || ""; + } else if (node.nodeType === Node.ELEMENT_NODE) { + const el = node as HTMLElement; + 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 || ""; + } + } + }); + + isInternalUpdate.current = true; + onChange(rawString); + }; + + return ( +
+ ); +} diff --git a/apps/web/app/components/ui/TestPanel.tsx b/apps/web/app/components/ui/TestPanel.tsx index 320d708..45bc0b0 100644 --- a/apps/web/app/components/ui/TestPanel.tsx +++ b/apps/web/app/components/ui/TestPanel.tsx @@ -25,13 +25,18 @@ export function TestPanel({ testResult, nodeName, nodeIcon }: TestPanelProps) {
{key}
-
+
0 && typeof value[0] === 'object' ? 'p-0' : 'px-4 py-2.5'}`}> {isNested ? ( Array.isArray(value) ? ( - [{value.map(v => typeof v === 'string' ? `"${v}"` : String(v)).join(', ')}] + value.length > 0 && typeof value[0] === 'object' ? ( +
{renderArrayOfObjectsTable(value, true)}
+ ) : ( + [{value.map(v => typeof v === 'string' ? `"${v}"` : String(v)).join(', ')}] + ) ) : ( - Object +
{renderObjectTable(value)}
) + ) : typeof value === 'boolean' ? ( {String(value)} ) : value === null || value === undefined ? ( @@ -84,7 +89,7 @@ export function TestPanel({ testResult, nodeName, nodeIcon }: TestPanelProps) { }; // Array of objects table - const renderArrayOfObjectsTable = (data: any[]) => { + const renderArrayOfObjectsTable = (data: any[], isNestedTable = false) => { const keysSet = new Set(); data.slice(0, 100).forEach(item => { if (item && typeof item === 'object') Object.keys(item).forEach(k => keysSet.add(k)); @@ -106,31 +111,30 @@ 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}
+ + + + {data.slice(0, 100).map((item, ri) => ( + + {ri} + {headers.map(h => { + const val = item?.[h]; + const display = typeof val === 'object' && val !== null ? JSON.stringify(val) : String(val ?? ''); + return ( + {display} + ); + })} + + ))} + +
); }; diff --git a/apps/web/app/components/ui/variable-panel.tsx b/apps/web/app/components/ui/variable-panel.tsx index 336a7a6..f72400a 100644 --- a/apps/web/app/components/ui/variable-panel.tsx +++ b/apps/web/app/components/ui/variable-panel.tsx @@ -62,7 +62,7 @@ export function VariablePanel({ previousNodes, onInsert, activeField, onTestNode ); } - const formattedNodeName = node.nodeName.toLowerCase().replace(/\s+/g, '_'); + const formattedNodeName = node.nodeId const renderRows = (vars: VariableDefinition[], depth: number = 0, currParentPath: string = "") => { return vars.map((variable, idx) => { @@ -141,8 +141,8 @@ export function VariablePanel({ previousNodes, onInsert, activeField, onTestNode }; // Rendering for Spreadsheet (Arrays of arrays) - const renderSpreadsheetTable = (nodeName: string, data: any) => { - const formattedNodeName = nodeName.toLowerCase().replace(/\s+/g, '_'); + const renderSpreadsheetTable = (nodeName: string, nodeId: string, data: any) => { + const formattedNodeName = nodeId; const rows = data.rows || data; // Handle data directly if it's the 2D array if (!Array.isArray(rows) || rows.length === 0) return null; @@ -152,7 +152,16 @@ export function VariablePanel({ previousNodes, onInsert, activeField, onTestNode return (
- Spreadsheet Data +
+ Spreadsheet Data + +
{dataRows.length} rows
@@ -199,8 +208,8 @@ export function VariablePanel({ previousNodes, onInsert, activeField, onTestNode }; // Rendering for standard array of objects - const renderArrayTable = (nodeName: string, dataArray: any[], isTested: boolean) => { - const formattedNodeName = nodeName.toLowerCase().replace(/\s+/g, '_'); + const renderArrayTable = (nodeName: string, nodeId: string, dataArray: any[], isTested: boolean) => { + const formattedNodeName = nodeId; // Find all unique keys across objects to form headers const keysSet = new Set(); @@ -451,17 +460,17 @@ export function VariablePanel({ previousNodes, onInsert, activeField, onTestNode {/* Try matching Spreadsheet Pattern */} {testOutput.data.rows && Array.isArray(testOutput.data.rows) && testOutput.data.rows.length > 0 && Array.isArray(testOutput.data.rows[0]) ? (
- {renderSpreadsheetTable(node.nodeName, testOutput.data)} + {renderSpreadsheetTable(node.nodeName, node.nodeId, testOutput.data)}
) : Array.isArray(testOutput.data) && testOutput.data.length > 0 && Array.isArray(testOutput.data[0]) ? (
- {renderSpreadsheetTable(node.nodeName, testOutput.data)} + {renderSpreadsheetTable(node.nodeName, node.nodeId, testOutput.data)}
) : /* Try matching Standard Array pattern */ Array.isArray(testOutput.data) ? (
- {renderArrayTable(node.nodeName, testOutput.data, isTested)} + {renderArrayTable(node.nodeName, node.nodeId, testOutput.data, isTested)}
) : ( /* Fallback to Tree Table if it's an object or string */ diff --git a/apps/web/app/lib/nodeConfigs/filter.action.ts b/apps/web/app/lib/nodeConfigs/filter.action.ts new file mode 100644 index 0000000..50eb112 --- /dev/null +++ b/apps/web/app/lib/nodeConfigs/filter.action.ts @@ -0,0 +1,70 @@ +import { NodeConfig } from "../types/node.types"; + +export const filterActionConfig: NodeConfig = { + id: "filter", // MUST match the 'type' in your backend registry + type: "action", + label: "Data Filter", + icon: "/filtering.png", + description: "Deduplicate and cross-reference datasets", + + fields: [ + { + name: "operation", + label: "Filter Operation", + type: "dropdown", + options: [ + { label: "Remove Duplicates from Provided Data(Single List)", id: "unique_rows" }, + { label: "Find New Data Only (Compare Two Lists)", id: "new_data_only" }, + { label: "Find Existing Data Only (Compare Two Lists)", id: "existing_data_only" } + ], + required: true, + defaultValue: "unique_rows", + description: "Select how you want to filter your data" + }, + { + name: "sourceData", + label: "Source Data (JSON Array)", + type: "textarea", + placeholder: '{{ GoogleSheet.rows }}', + required: true, + description: "The primary array of data you want to filter" + }, + { + name: "referenceData", + label: "Reference Data (JSON Array)", + type: "textarea", + placeholder: '{{ Database.rows }}', + required: false, + dependsOn: "operation", + showForOperation: ["new_data_only", "existing_data_only"], // Hides this field if unique_rows is selected! + description: "The master list to compare against" + }, + { + name: "sourceKey", + label: "Source Column / Key", + type: "dynamic_schema_dropdown", + required: false, + description: "The specific column to use for comparison. (Optional for deduplication, required for comparisons)" + }, + { + name: "referenceKey", + label: "Reference Column / Key", + type: "dynamic_schema_dropdown", + required: false, + dependsOn: "operation", + showForOperation: ["new_data_only", "existing_data_only"], + description: "The column in the Reference Data to match against." + } + ], + + summary: "Filter arrays and remove duplicates", + helpUrl: "", + + outputSchema: [ + { name: "Filtered Data", path: "filteredData", type: "array", description: "The data that passed the filter" }, + { name: "Discarded Data", path: "discardedData", type: "array", description: "The data that was thrown away" }, + { name: "Operation Used", path: "metadata.operation_used", type: "string" }, + { name: "Items Kept", path: "metadata.items_kept", type: "number" }, + { name: "Items Discarded", path: "metadata.items_discarded", type: "number" }, + ] +}; diff --git a/apps/web/app/lib/nodeConfigs/index.ts b/apps/web/app/lib/nodeConfigs/index.ts index 19afaf8..21195e7 100644 --- a/apps/web/app/lib/nodeConfigs/index.ts +++ b/apps/web/app/lib/nodeConfigs/index.ts @@ -1,4 +1,5 @@ import { NodeConfig } from "../types/node.types"; +import { filterActionConfig } from "./filter.action"; import { gmailActionConfig } from "./gmail.action"; import { googleSheetActionConfig } from "./googleSheet.action"; import { webhookTriggerConfig } from "./webhook.trigger"; @@ -11,11 +12,13 @@ export const NODE_CONFIG_REGISTRY: Record = { [gmailActionConfig.label]: gmailActionConfig, [googleSheetActionConfig.label]: googleSheetActionConfig, [webhookTriggerConfig.label]: webhookTriggerConfig, + [filterActionConfig.label]: filterActionConfig, // Map by ID (internal safety fallback) [gmailActionConfig.id]: gmailActionConfig, // "gmail" [googleSheetActionConfig.id]: googleSheetActionConfig, // "google_sheet" [webhookTriggerConfig.id]: webhookTriggerConfig, // "webhook" + [filterActionConfig.id]: filterActionConfig, }; /** diff --git a/apps/web/app/lib/types/node.types.ts b/apps/web/app/lib/types/node.types.ts index 1b79e24..77fbe27 100644 --- a/apps/web/app/lib/types/node.types.ts +++ b/apps/web/app/lib/types/node.types.ts @@ -46,7 +46,7 @@ export interface NodeConfig { export interface ConfigField { name: string; // Field's internal key, e.g., "sheetId" label: string; // Human-readable label, e.g., "Sheet ID" - type: "text" | "dropdown" | "textarea" | "number" | "checkbox" | "password" | "column_mapper" | "bulk_payload"; + type: "text" | "dropdown" | "textarea" | "number" | "checkbox" | "password" | "column_mapper" | "bulk_payload" | "dynamic_schema_dropdown"; required?: boolean; defaultValue?: string | number | boolean; // Initial value if not set placeholder?: string; diff --git a/apps/web/app/workflows/[id]/components/ConfigModal.tsx b/apps/web/app/workflows/[id]/components/ConfigModal.tsx index e18eead..06e772a 100644 --- a/apps/web/app/workflows/[id]/components/ConfigModal.tsx +++ b/apps/web/app/workflows/[id]/components/ConfigModal.tsx @@ -19,6 +19,7 @@ import { } from "@/store/slices/nodeOutputSlice"; import { workflowActions } from "@/store/slices/workflowSlice"; import { TestPanel } from "@/app/components/ui/TestPanel"; +import { RichVariableInput } from "@/app/components/ui/RichVariableInput"; interface ConfigModalProps { isOpen: boolean; @@ -59,6 +60,11 @@ export default function ConfigModal({ // Get all tested outputs from Redux (for variable resolution) const allTestedOutputs = useAppSelector(selectAllOutputs); + + const availableNodes = Object.entries(allTestedOutputs).map(([id, output]) => ({ + id, + name: output.nodeName || 'Node' + })); // Get test output from Redux for this node const nodeTestOutput = useAppSelector((state) => @@ -423,9 +429,88 @@ export default function ConfigModal({ return true } + const extractSchemaKeys = (data: any): string[] => { + if (!Array.isArray(data)) return ["Invalid Data (Expected an Array)"]; + if (data.length === 0) return ["(Array is Empty - No Keys Found)"]; + if (typeof data[0] !== 'object' || data[0] === null) return ["(Value Itself)"]; + if (Array.isArray(data[0])) { + return data[0].map((h: any) => String(h)); + } + + const keys: string[] = []; + const extractKeysRecursive = (obj: any, prefix = "") => { + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + const newKey = prefix ? `${prefix}.${key}` : key; + const value = obj[key]; + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + extractKeysRecursive(value, newKey); + } else { + keys.push(newKey); + } + } + } + }; + + extractKeysRecursive(data[0]); + return Array.from(new Set(keys)); + }; + const renderField = (field: ConfigField, nodeConfig: any) => { const fieldValue = config[field.name] ?? field.defaultValue ?? ""; + if (field.type === "dynamic_schema_dropdown") { + let options: string[] = ["(No Data Mapped)"]; + + const dependentFieldName = field.name === "sourceKey" ? "sourceData" : "referenceData"; + const dependentFieldValue = config[dependentFieldName]; + + if (dependentFieldValue && typeof dependentFieldValue === 'string') { + const interpolationContext = buildTestContext(); + const resolvedConfig = resolveConfigVariables({ temp: dependentFieldValue }, interpolationContext); + const resolvedData = resolvedConfig.temp; + + if (resolvedData !== dependentFieldValue) { + options = extractSchemaKeys(resolvedData); + } else { + options = ["(Test Node to Load Options)"]; + } + } + + return ( +
+ + +
+ ); + } + if (field.type === "dropdown" && field.name === "credentialId") { // Use the values from useCredentials: credentials and authUrl return ( @@ -532,7 +617,7 @@ export default function ConfigModal({ {field.label} {field.required && *} -