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 => (
- {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} |
- );
- })}
-
+
+
+
+ | # |
+ {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 && *}
-