feat: (Execute Workflow Node): Inputs for Sub-workflows (#11830) (#11837)

Co-authored-by: Charlie Kolb <charlie@n8n.io>
Co-authored-by: Milorad FIlipović <milorad@n8n.io>
Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
This commit is contained in:
Ivan Atanasov 2024-12-20 17:01:22 +01:00 committed by GitHub
parent 6c323e4e49
commit d4116630a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 4023 additions and 688 deletions

View file

@ -0,0 +1,288 @@
import { clickGetBackToCanvas, getOutputTableHeaders } from '../composables/ndv';
import {
clickZoomToFit,
navigateToNewWorkflowPage,
openNode,
pasteWorkflow,
saveWorkflowOnButtonClick,
} from '../composables/workflow';
import SUB_WORKFLOW_INPUTS from '../fixtures/Test_Subworkflow-Inputs.json';
import { NDV, WorkflowsPage, WorkflowPage } from '../pages';
import { errorToast, successToast } from '../pages/notifications';
import { getVisiblePopper } from '../utils';
const ndv = new NDV();
const workflowsPage = new WorkflowsPage();
const workflow = new WorkflowPage();
const DEFAULT_WORKFLOW_NAME = 'My workflow';
const DEFAULT_SUBWORKFLOW_NAME_1 = 'My Sub-Workflow 1';
const DEFAULT_SUBWORKFLOW_NAME_2 = 'My Sub-Workflow 2';
type FieldRow = readonly string[];
const exampleFields = [
['aNumber', 'Number'],
['aString', 'String'],
['aArray', 'Array'],
['aObject', 'Object'],
['aAny', 'Allow Any Type'],
// bool last since it's not an inputField so we'll skip it for some cases
['aBool', 'Boolean'],
] as const;
/**
* Populate multiValue fixedCollections. Only supports fixedCollections for which all fields can be defined via keyboard typing
*
* @param items - 2D array of items to populate, i.e. [["myField1", "String"], [""]
* @param collectionName - name of the fixedCollection to populate
* @param offset - amount of 'parameter-input's before the fixedCollection under test
* @returns
*/
function populateFixedCollection(
items: readonly FieldRow[],
collectionName: string,
offset: number,
) {
if (items.length === 0) return;
const n = items[0].length;
for (const [i, params] of items.entries()) {
ndv.actions.addItemToFixedCollection(collectionName);
for (const [j, param] of params.entries()) {
ndv.getters
.fixedCollectionParameter(collectionName)
.getByTestId('parameter-input')
.eq(offset + i * n + j)
.type(`${param}{downArrow}{enter}`);
}
}
}
function makeExample(type: TypeField) {
switch (type) {
case 'String':
return '"example"';
case 'Number':
return '42';
case 'Boolean':
return 'true';
case 'Array':
return '["example", 123, null]';
case 'Object':
return '{{}"example": [123]}';
case 'Allow Any Type':
return 'null';
}
}
type TypeField = 'Allow Any Type' | 'String' | 'Number' | 'Boolean' | 'Array' | 'Object';
function populateFields(items: ReadonlyArray<readonly [string, TypeField]>) {
populateFixedCollection(items, 'workflowInputs', 1);
}
function navigateWorkflowSelectionDropdown(index: number, expectedText: string) {
ndv.getters.resourceLocator('workflowId').should('be.visible');
ndv.getters.resourceLocatorInput('workflowId').click();
getVisiblePopper().findChildByTestId('rlc-item').eq(0).should('exist');
getVisiblePopper()
.findChildByTestId('rlc-item')
.eq(index)
.find('span')
.should('have.text', expectedText)
.click();
}
function populateMapperFields(values: readonly string[], offset: number) {
for (const [i, value] of values.entries()) {
cy.getByTestId('parameter-input')
.eq(offset + i)
.type(value);
// Click on a parent to dismiss the pop up hiding the field below.
cy.getByTestId('parameter-input')
.eq(offset + i)
.parent()
.parent()
.click('topLeft');
}
}
// This function starts off in the Child Workflow Input Trigger, assuming we just defined the input fields
// It then navigates back to the parent and validates output
function validateAndReturnToParent(targetChild: string, offset: number, fields: string[]) {
ndv.actions.execute();
// + 1 to account for formatting-only column
getOutputTableHeaders().should('have.length', fields.length + 1);
for (const [i, name] of fields.entries()) {
getOutputTableHeaders().eq(i).should('have.text', name);
}
clickGetBackToCanvas();
saveWorkflowOnButtonClick();
cy.visit(workflowsPage.url);
workflowsPage.getters.workflowCardContent(DEFAULT_WORKFLOW_NAME).click();
openNode('Execute Workflow');
// Note that outside of e2e tests this will be pre-selected correctly.
// Due to our workaround to remain in the same tab we need to select the correct tab manually
navigateWorkflowSelectionDropdown(offset, targetChild);
// This fails, pointing to `usePushConnection` `const triggerNode = subWorkflow?.nodes.find` being `undefined.find()`I <think>
ndv.actions.execute();
getOutputTableHeaders().should('have.length', fields.length + 1);
for (const [i, name] of fields.entries()) {
getOutputTableHeaders().eq(i).should('have.text', name);
}
// todo: verify the fields appear and show the correct types
// todo: fill in the input fields (and mock previous node data in the json fixture to match)
// todo: validate the actual output data
}
function setWorkflowInputFieldValue(index: number, value: string) {
ndv.actions.addItemToFixedCollection('workflowInputs');
ndv.actions.typeIntoFixedCollectionItem('workflowInputs', index, value);
}
describe('Sub-workflow creation and typed usage', () => {
beforeEach(() => {
navigateToNewWorkflowPage();
pasteWorkflow(SUB_WORKFLOW_INPUTS);
saveWorkflowOnButtonClick();
clickZoomToFit();
openNode('Execute Workflow');
// Prevent sub-workflow from opening in new window
cy.window().then((win) => {
cy.stub(win, 'open').callsFake((url) => {
cy.visit(url);
});
});
navigateWorkflowSelectionDropdown(0, 'Create a new sub-workflow');
// **************************
// NAVIGATE TO CHILD WORKFLOW
// **************************
openNode('Workflow Input Trigger');
});
it('works with type-checked values', () => {
populateFields(exampleFields);
validateAndReturnToParent(
DEFAULT_SUBWORKFLOW_NAME_1,
1,
exampleFields.map((f) => f[0]),
);
const values = [
'-1', // number fields don't support `=` switch to expression, so let's test the Fixed case with it
...exampleFields.slice(1).map((x) => `={{}{{} $json.a${x[0]}`), // }} are added automatically
];
// this matches with the pinned data provided in the fixture
populateMapperFields(values, 2);
ndv.actions.execute();
// todo:
// - validate output lines up
// - change input to need casts
// - run
// - confirm error
// - switch `attemptToConvertTypes` flag
// - confirm success and changed output
// - change input to be invalid despite cast
// - run
// - confirm error
// - switch type option flags
// - run
// - confirm success
// - turn off attempt to cast flag
// - confirm a value was not cast
});
it('works with Fields input source into JSON input source', () => {
ndv.getters.nodeOutputHint().should('exist');
populateFields(exampleFields);
validateAndReturnToParent(
DEFAULT_SUBWORKFLOW_NAME_1,
1,
exampleFields.map((f) => f[0]),
);
cy.window().then((win) => {
cy.stub(win, 'open').callsFake((url) => {
cy.visit(url);
});
});
navigateWorkflowSelectionDropdown(0, 'Create a new sub-workflow');
openNode('Workflow Input Trigger');
cy.getByTestId('parameter-input').eq(0).click();
// Todo: Check if there's a better way to interact with option dropdowns
// This PR would add this child testId
getVisiblePopper()
.getByTestId('parameter-input')
.eq(0)
.type('Using JSON Example{downArrow}{enter}');
const exampleJson =
'{{}' + exampleFields.map((x) => `"${x[0]}": ${makeExample(x[1])}`).join(',') + '}';
cy.getByTestId('parameter-input-jsonExample')
.find('.cm-line')
.eq(0)
.type(`{selectAll}{backspace}${exampleJson}{enter}`);
// first one doesn't work for some reason, might need to wait for something?
ndv.actions.execute();
validateAndReturnToParent(
DEFAULT_SUBWORKFLOW_NAME_2,
2,
exampleFields.map((f) => f[0]),
);
// test for either InputSource mode and options combinations:
// + we're showing the notice in the output panel
// + we start with no fields
// + Test Step works and we create the fields
// + create field of each type (string, number, boolean, object, array, any)
// + exit ndv
// + save
// + go back to parent workflow
// - verify fields appear [needs Ivan's PR]
// - link fields [needs Ivan's PR]
// + run parent
// - verify output with `null` defaults exists
//
});
it('should show node issue when no fields are defined in manual mode', () => {
ndv.getters.nodeExecuteButton().should('be.disabled');
ndv.actions.close();
// Executing the workflow should show an error toast
workflow.actions.executeWorkflow();
errorToast().should('contain', 'The workflow has issues');
openNode('Workflow Input Trigger');
// Add a field to the workflowInputs fixedCollection
setWorkflowInputFieldValue(0, 'test');
// Executing the workflow should not show error now
ndv.actions.close();
workflow.actions.executeWorkflow();
successToast().should('contain', 'Workflow executed successfully');
});
});

View file

@ -0,0 +1,70 @@
{
"meta": {
"instanceId": "4d0676b62208d810ef035130bbfc9fd3afdc78d963ea8ccb9514dc89066efc94"
},
"nodes": [
{
"parameters": {},
"id": "bb7f8bb3-840a-464c-a7de-d3a80538c2be",
"name": "When clicking Test workflow",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0]
},
{
"parameters": {
"workflowId": {},
"workflowInputs": {
"mappingMode": "defineBelow",
"value": {},
"matchingColumns": [],
"schema": [],
"ignoreTypeMismatchErrors": false,
"attemptToConvertTypes": false,
"convertFieldsToString": true
},
"options": {}
},
"type": "n8n-nodes-base.executeWorkflow",
"typeVersion": 1.2,
"position": [500, 240],
"id": "6b6e2e34-c6ab-4083-b8e3-6b0d56be5453",
"name": "Execute Workflow"
}
],
"connections": {
"When clicking Test workflow": {
"main": [
[
{
"node": "Execute Workflow",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {
"When clicking Test workflow": [
{
"aaString": "A String",
"aaNumber": 1,
"aaArray": [1, true, "3"],
"aaObject": {
"aKey": -1
},
"aaAny": {}
},
{
"aaString": "Another String",
"aaNumber": 2,
"aaArray": [],
"aaObject": {
"aDifferentKey": -1
},
"aaAny": []
}
]
}
}

View file

@ -320,6 +320,11 @@ export class NDV extends BasePage {
addItemToFixedCollection: (paramName: string) => {
this.getters.fixedCollectionParameter(paramName).getByTestId('fixed-collection-add').click();
},
typeIntoFixedCollectionItem: (fixedCollectionName: string, index: number, content: string) => {
this.getters.fixedCollectionParameter(fixedCollectionName).within(() => {
cy.getByTestId('parameter-input').eq(index).type(content);
});
},
dragMainPanelToLeft: () => {
cy.drag('[data-test-id=panel-drag-button]', [-1000, 0], { moveTwice: true });
},

View file

@ -1,567 +1,42 @@
import type { CallbackManagerForToolRun } from '@langchain/core/callbacks/manager';
import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools';
import type { JSONSchema7 } from 'json-schema';
import get from 'lodash/get';
import isObject from 'lodash/isObject';
import type { SetField, SetNodeOptions } from 'n8n-nodes-base/dist/nodes/Set/v2/helpers/interfaces';
import * as manual from 'n8n-nodes-base/dist/nodes/Set/v2/manual.mode';
import type {
IExecuteWorkflowInfo,
INodeExecutionData,
INodeType,
INodeTypeDescription,
IWorkflowBase,
ISupplyDataFunctions,
SupplyData,
ExecutionError,
ExecuteWorkflowData,
IDataObject,
INodeParameterResourceLocator,
ITaskMetadata,
} from 'n8n-workflow';
import { NodeConnectionType, NodeOperationError, jsonParse } from 'n8n-workflow';
import type { IVersionedNodeType, INodeTypeBaseDescription } from 'n8n-workflow';
import { VersionedNodeType } from 'n8n-workflow';
import { jsonSchemaExampleField, schemaTypeField, inputSchemaField } from '@utils/descriptions';
import { convertJsonSchemaToZod, generateSchema } from '@utils/schemaParsing';
import { getConnectionHintNoticeField } from '@utils/sharedFields';
import { ToolWorkflowV1 } from './v1/ToolWorkflowV1.node';
import { ToolWorkflowV2 } from './v2/ToolWorkflowV2.node';
import type { DynamicZodObject } from '../../../types/zod.types';
export class ToolWorkflow implements INodeType {
description: INodeTypeDescription = {
displayName: 'Call n8n Workflow Tool',
name: 'toolWorkflow',
icon: 'fa:network-wired',
iconColor: 'black',
group: ['transform'],
version: [1, 1.1, 1.2, 1.3],
description: 'Uses another n8n workflow as a tool. Allows packaging any n8n node(s) as a tool.',
defaults: {
name: 'Call n8n Workflow Tool',
},
codex: {
categories: ['AI'],
subcategories: {
AI: ['Tools'],
Tools: ['Recommended Tools'],
},
resources: {
primaryDocumentation: [
{
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolworkflow/',
},
],
},
},
// eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node
inputs: [],
// eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong
outputs: [NodeConnectionType.AiTool],
outputNames: ['Tool'],
properties: [
getConnectionHintNoticeField([NodeConnectionType.AiAgent]),
{
displayName:
'See an example of a workflow to suggest meeting slots using AI <a href="/templates/1953" target="_blank">here</a>.',
name: 'noticeTemplateExample',
type: 'notice',
default: '',
},
{
displayName: 'Name',
name: 'name',
type: 'string',
default: '',
placeholder: 'My_Color_Tool',
displayOptions: {
show: {
'@version': [1],
},
export class ToolWorkflow extends VersionedNodeType {
constructor() {
const baseDescription: INodeTypeBaseDescription = {
displayName: 'Call n8n Sub-Workflow Tool',
name: 'toolWorkflow',
icon: 'fa:network-wired',
group: ['transform'],
description:
'Uses another n8n workflow as a tool. Allows packaging any n8n node(s) as a tool.',
codex: {
categories: ['AI'],
subcategories: {
AI: ['Tools'],
Tools: ['Recommended Tools'],
},
},
{
displayName: 'Name',
name: 'name',
type: 'string',
default: '',
placeholder: 'e.g. My_Color_Tool',
validateType: 'string-alphanumeric',
description:
'The name of the function to be called, could contain letters, numbers, and underscores only',
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.1 } }],
},
},
},
{
displayName: 'Description',
name: 'description',
type: 'string',
default: '',
placeholder:
'Call this tool to get a random color. The input should be a string with comma separted names of colors to exclude.',
typeOptions: {
rows: 3,
},
},
{
displayName:
'This tool will call the workflow you define below, and look in the last node for the response. The workflow needs to start with an Execute Workflow trigger',
name: 'executeNotice',
type: 'notice',
default: '',
},
{
displayName: 'Source',
name: 'source',
type: 'options',
options: [
{
name: 'Database',
value: 'database',
description: 'Load the workflow from the database by ID',
},
{
name: 'Define Below',
value: 'parameter',
description: 'Pass the JSON code of a workflow',
},
],
default: 'database',
description: 'Where to get the workflow to execute from',
},
// ----------------------------------
// source:database
// ----------------------------------
{
displayName: 'Workflow ID',
name: 'workflowId',
type: 'string',
displayOptions: {
show: {
source: ['database'],
'@version': [{ _cnd: { lte: 1.1 } }],
},
},
default: '',
required: true,
description: 'The workflow to execute',
hint: 'Can be found in the URL of the workflow',
},
{
displayName: 'Workflow',
name: 'workflowId',
type: 'workflowSelector',
displayOptions: {
show: {
source: ['database'],
'@version': [{ _cnd: { gte: 1.2 } }],
},
},
default: '',
required: true,
},
// ----------------------------------
// source:parameter
// ----------------------------------
{
displayName: 'Workflow JSON',
name: 'workflowJson',
type: 'json',
typeOptions: {
rows: 10,
},
displayOptions: {
show: {
source: ['parameter'],
},
},
default: '\n\n\n\n\n\n\n\n\n',
required: true,
description: 'The workflow JSON code to execute',
},
// ----------------------------------
// For all
// ----------------------------------
{
displayName: 'Field to Return',
name: 'responsePropertyName',
type: 'string',
default: 'response',
required: true,
hint: 'The field in the last-executed node of the workflow that contains the response',
description:
'Where to find the data that this tool should return. n8n will look in the output of the last-executed node of the workflow for a field with this name, and return its value.',
displayOptions: {
show: {
'@version': [{ _cnd: { lt: 1.3 } }],
},
},
},
{
displayName: 'Extra Workflow Inputs',
name: 'fields',
placeholder: 'Add Value',
type: 'fixedCollection',
description:
"These will be output by the 'execute workflow' trigger of the workflow being called",
typeOptions: {
multipleValues: true,
sortable: true,
},
default: {},
options: [
{
name: 'values',
displayName: 'Values',
values: [
{
displayName: 'Name',
name: 'name',
type: 'string',
default: '',
placeholder: 'e.g. fieldName',
description:
'Name of the field to set the value of. Supports dot-notation. Example: data.person[0].name.',
requiresDataPath: 'single',
},
{
displayName: 'Type',
name: 'type',
type: 'options',
description: 'The field value type',
// eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items
options: [
{
name: 'String',
value: 'stringValue',
},
{
name: 'Number',
value: 'numberValue',
},
{
name: 'Boolean',
value: 'booleanValue',
},
{
name: 'Array',
value: 'arrayValue',
},
{
name: 'Object',
value: 'objectValue',
},
],
default: 'stringValue',
},
{
displayName: 'Value',
name: 'stringValue',
type: 'string',
default: '',
displayOptions: {
show: {
type: ['stringValue'],
},
},
validateType: 'string',
ignoreValidationDuringExecution: true,
},
{
displayName: 'Value',
name: 'numberValue',
type: 'string',
default: '',
displayOptions: {
show: {
type: ['numberValue'],
},
},
validateType: 'number',
ignoreValidationDuringExecution: true,
},
{
displayName: 'Value',
name: 'booleanValue',
type: 'options',
default: 'true',
options: [
{
name: 'True',
value: 'true',
},
{
name: 'False',
value: 'false',
},
],
displayOptions: {
show: {
type: ['booleanValue'],
},
},
validateType: 'boolean',
ignoreValidationDuringExecution: true,
},
{
displayName: 'Value',
name: 'arrayValue',
type: 'string',
default: '',
placeholder: 'e.g. [ arrayItem1, arrayItem2, arrayItem3 ]',
displayOptions: {
show: {
type: ['arrayValue'],
},
},
validateType: 'array',
ignoreValidationDuringExecution: true,
},
{
displayName: 'Value',
name: 'objectValue',
type: 'json',
default: '={}',
typeOptions: {
rows: 2,
},
displayOptions: {
show: {
type: ['objectValue'],
},
},
validateType: 'object',
ignoreValidationDuringExecution: true,
},
],
},
],
},
// ----------------------------------
// Output Parsing
// ----------------------------------
{
displayName: 'Specify Input Schema',
name: 'specifyInputSchema',
type: 'boolean',
description:
'Whether to specify the schema for the function. This would require the LLM to provide the input in the correct format and would validate it against the schema.',
noDataExpression: true,
default: false,
},
{ ...schemaTypeField, displayOptions: { show: { specifyInputSchema: [true] } } },
jsonSchemaExampleField,
inputSchemaField,
],
};
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
const workflowProxy = this.getWorkflowDataProxy(0);
const name = this.getNodeParameter('name', itemIndex) as string;
const description = this.getNodeParameter('description', itemIndex) as string;
let subExecutionId: string | undefined;
let subWorkflowId: string | undefined;
const useSchema = this.getNodeParameter('specifyInputSchema', itemIndex) as boolean;
let tool: DynamicTool | DynamicStructuredTool | undefined = undefined;
const runFunction = async (
query: string | IDataObject,
runManager?: CallbackManagerForToolRun,
): Promise<string> => {
const source = this.getNodeParameter('source', itemIndex) as string;
const workflowInfo: IExecuteWorkflowInfo = {};
if (source === 'database') {
// Read workflow from database
const nodeVersion = this.getNode().typeVersion;
if (nodeVersion <= 1.1) {
workflowInfo.id = this.getNodeParameter('workflowId', itemIndex) as string;
} else {
const { value } = this.getNodeParameter(
'workflowId',
itemIndex,
{},
) as INodeParameterResourceLocator;
workflowInfo.id = value as string;
}
subWorkflowId = workflowInfo.id;
} else if (source === 'parameter') {
// Read workflow from parameter
const workflowJson = this.getNodeParameter('workflowJson', itemIndex) as string;
try {
workflowInfo.code = JSON.parse(workflowJson) as IWorkflowBase;
// subworkflow is same as parent workflow
subWorkflowId = workflowProxy.$workflow.id;
} catch (error) {
throw new NodeOperationError(
this.getNode(),
`The provided workflow is not valid JSON: "${(error as Error).message}"`,
resources: {
primaryDocumentation: [
{
itemIndex,
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolworkflow/',
},
);
}
}
const rawData: IDataObject = { query };
const workflowFieldsJson = this.getNodeParameter('fields.values', itemIndex, [], {
rawExpressions: true,
}) as SetField[];
// Copied from Set Node v2
for (const entry of workflowFieldsJson) {
if (entry.type === 'objectValue' && (entry.objectValue as string).startsWith('=')) {
rawData[entry.name] = (entry.objectValue as string).replace(/^=+/, '');
}
}
const options: SetNodeOptions = {
include: 'all',
};
const newItem = await manual.execute.call(
this,
{ json: { query } },
itemIndex,
options,
rawData,
this.getNode(),
);
const items = [newItem] as INodeExecutionData[];
let receivedData: ExecuteWorkflowData;
try {
receivedData = await this.executeWorkflow(workflowInfo, items, runManager?.getChild(), {
parentExecution: {
executionId: workflowProxy.$execution.id,
workflowId: workflowProxy.$workflow.id,
},
});
subExecutionId = receivedData.executionId;
} catch (error) {
// Make sure a valid error gets returned that can by json-serialized else it will
// not show up in the frontend
throw new NodeOperationError(this.getNode(), error as Error);
}
const response: string | undefined = get(receivedData, 'data[0][0].json') as
| string
| undefined;
if (response === undefined) {
throw new NodeOperationError(
this.getNode(),
'There was an error: "The workflow did not return a response"',
);
}
return response;
],
},
},
defaultVersion: 2,
};
const toolHandler = async (
query: string | IDataObject,
runManager?: CallbackManagerForToolRun,
): Promise<string> => {
const { index } = this.addInputData(NodeConnectionType.AiTool, [[{ json: { query } }]]);
let response: string = '';
let executionError: ExecutionError | undefined;
try {
response = await runFunction(query, runManager);
} catch (error) {
// TODO: Do some more testing. Issues here should actually fail the workflow
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
executionError = error;
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
response = `There was an error: "${error.message}"`;
}
if (typeof response === 'number') {
response = (response as number).toString();
}
if (isObject(response)) {
response = JSON.stringify(response, null, 2);
}
if (typeof response !== 'string') {
// TODO: Do some more testing. Issues here should actually fail the workflow
executionError = new NodeOperationError(this.getNode(), 'Wrong output type returned', {
description: `The response property should be a string, but it is an ${typeof response}`,
});
response = `There was an error: "${executionError.message}"`;
}
let metadata: ITaskMetadata | undefined;
if (subExecutionId && subWorkflowId) {
metadata = {
subExecution: {
executionId: subExecutionId,
workflowId: subWorkflowId,
},
};
}
if (executionError) {
void this.addOutputData(NodeConnectionType.AiTool, index, executionError, metadata);
} else {
// Output always needs to be an object
// so we try to parse the response as JSON and if it fails we just return the string wrapped in an object
const json = jsonParse<IDataObject>(response, { fallbackValue: { response } });
void this.addOutputData(NodeConnectionType.AiTool, index, [[{ json }]], metadata);
}
return response;
};
const functionBase = {
name,
description,
func: toolHandler,
};
if (useSchema) {
try {
// We initialize these even though one of them will always be empty
// it makes it easier to navigate the ternary operator
const jsonExample = this.getNodeParameter('jsonSchemaExample', itemIndex, '') as string;
const inputSchema = this.getNodeParameter('inputSchema', itemIndex, '') as string;
const schemaType = this.getNodeParameter('schemaType', itemIndex) as 'fromJson' | 'manual';
const jsonSchema =
schemaType === 'fromJson'
? generateSchema(jsonExample)
: jsonParse<JSONSchema7>(inputSchema);
const zodSchema = convertJsonSchemaToZod<DynamicZodObject>(jsonSchema);
tool = new DynamicStructuredTool({
schema: zodSchema,
...functionBase,
});
} catch (error) {
throw new NodeOperationError(
this.getNode(),
'Error during parsing of JSON Schema. \n ' + error,
);
}
} else {
tool = new DynamicTool(functionBase);
}
return {
response: tool,
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
1: new ToolWorkflowV1(baseDescription),
1.1: new ToolWorkflowV1(baseDescription),
1.2: new ToolWorkflowV1(baseDescription),
1.3: new ToolWorkflowV1(baseDescription),
2: new ToolWorkflowV2(baseDescription),
};
super(nodeVersions, baseDescription);
}
}

View file

@ -0,0 +1,241 @@
import type { CallbackManagerForToolRun } from '@langchain/core/callbacks/manager';
import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools';
import type { JSONSchema7 } from 'json-schema';
import get from 'lodash/get';
import isObject from 'lodash/isObject';
import type { SetField, SetNodeOptions } from 'n8n-nodes-base/dist/nodes/Set/v2/helpers/interfaces';
import * as manual from 'n8n-nodes-base/dist/nodes/Set/v2/manual.mode';
import type {
IExecuteWorkflowInfo,
INodeExecutionData,
INodeType,
INodeTypeDescription,
IWorkflowBase,
ISupplyDataFunctions,
SupplyData,
ExecutionError,
ExecuteWorkflowData,
IDataObject,
INodeParameterResourceLocator,
ITaskMetadata,
INodeTypeBaseDescription,
} from 'n8n-workflow';
import { NodeConnectionType, NodeOperationError, jsonParse } from 'n8n-workflow';
import { versionDescription } from './versionDescription';
import type { DynamicZodObject } from '../../../../types/zod.types';
import { convertJsonSchemaToZod, generateSchema } from '../../../../utils/schemaParsing';
export class ToolWorkflowV1 implements INodeType {
description: INodeTypeDescription;
constructor(baseDescription: INodeTypeBaseDescription) {
this.description = {
...baseDescription,
...versionDescription,
};
}
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
const workflowProxy = this.getWorkflowDataProxy(0);
const name = this.getNodeParameter('name', itemIndex) as string;
const description = this.getNodeParameter('description', itemIndex) as string;
let subExecutionId: string | undefined;
let subWorkflowId: string | undefined;
const useSchema = this.getNodeParameter('specifyInputSchema', itemIndex) as boolean;
let tool: DynamicTool | DynamicStructuredTool | undefined = undefined;
const runFunction = async (
query: string | IDataObject,
runManager?: CallbackManagerForToolRun,
): Promise<string> => {
const source = this.getNodeParameter('source', itemIndex) as string;
const workflowInfo: IExecuteWorkflowInfo = {};
if (source === 'database') {
// Read workflow from database
const nodeVersion = this.getNode().typeVersion;
if (nodeVersion <= 1.1) {
workflowInfo.id = this.getNodeParameter('workflowId', itemIndex) as string;
} else {
const { value } = this.getNodeParameter(
'workflowId',
itemIndex,
{},
) as INodeParameterResourceLocator;
workflowInfo.id = value as string;
}
subWorkflowId = workflowInfo.id;
} else if (source === 'parameter') {
// Read workflow from parameter
const workflowJson = this.getNodeParameter('workflowJson', itemIndex) as string;
try {
workflowInfo.code = JSON.parse(workflowJson) as IWorkflowBase;
// subworkflow is same as parent workflow
subWorkflowId = workflowProxy.$workflow.id;
} catch (error) {
throw new NodeOperationError(
this.getNode(),
`The provided workflow is not valid JSON: "${(error as Error).message}"`,
{
itemIndex,
},
);
}
}
const rawData: IDataObject = { query };
const workflowFieldsJson = this.getNodeParameter('fields.values', itemIndex, [], {
rawExpressions: true,
}) as SetField[];
// Copied from Set Node v2
for (const entry of workflowFieldsJson) {
if (entry.type === 'objectValue' && (entry.objectValue as string).startsWith('=')) {
rawData[entry.name] = (entry.objectValue as string).replace(/^=+/, '');
}
}
const options: SetNodeOptions = {
include: 'all',
};
const newItem = await manual.execute.call(
this,
{ json: { query } },
itemIndex,
options,
rawData,
this.getNode(),
);
const items = [newItem] as INodeExecutionData[];
let receivedData: ExecuteWorkflowData;
try {
receivedData = await this.executeWorkflow(workflowInfo, items, runManager?.getChild(), {
parentExecution: {
executionId: workflowProxy.$execution.id,
workflowId: workflowProxy.$workflow.id,
},
});
subExecutionId = receivedData.executionId;
} catch (error) {
// Make sure a valid error gets returned that can by json-serialized else it will
// not show up in the frontend
throw new NodeOperationError(this.getNode(), error as Error);
}
const response: string | undefined = get(receivedData, 'data[0][0].json') as
| string
| undefined;
if (response === undefined) {
throw new NodeOperationError(
this.getNode(),
'There was an error: "The workflow did not return a response"',
);
}
return response;
};
const toolHandler = async (
query: string | IDataObject,
runManager?: CallbackManagerForToolRun,
): Promise<string> => {
const { index } = this.addInputData(NodeConnectionType.AiTool, [[{ json: { query } }]]);
let response: string = '';
let executionError: ExecutionError | undefined;
try {
response = await runFunction(query, runManager);
} catch (error) {
// TODO: Do some more testing. Issues here should actually fail the workflow
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
executionError = error;
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
response = `There was an error: "${error.message}"`;
}
if (typeof response === 'number') {
response = (response as number).toString();
}
if (isObject(response)) {
response = JSON.stringify(response, null, 2);
}
if (typeof response !== 'string') {
// TODO: Do some more testing. Issues here should actually fail the workflow
executionError = new NodeOperationError(this.getNode(), 'Wrong output type returned', {
description: `The response property should be a string, but it is an ${typeof response}`,
});
response = `There was an error: "${executionError.message}"`;
}
let metadata: ITaskMetadata | undefined;
if (subExecutionId && subWorkflowId) {
metadata = {
subExecution: {
executionId: subExecutionId,
workflowId: subWorkflowId,
},
};
}
if (executionError) {
void this.addOutputData(NodeConnectionType.AiTool, index, executionError, metadata);
} else {
// Output always needs to be an object
// so we try to parse the response as JSON and if it fails we just return the string wrapped in an object
const json = jsonParse<IDataObject>(response, { fallbackValue: { response } });
void this.addOutputData(NodeConnectionType.AiTool, index, [[{ json }]], metadata);
}
return response;
};
const functionBase = {
name,
description,
func: toolHandler,
};
if (useSchema) {
try {
// We initialize these even though one of them will always be empty
// it makes it easier to navigate the ternary operator
const jsonExample = this.getNodeParameter('jsonSchemaExample', itemIndex, '') as string;
const inputSchema = this.getNodeParameter('inputSchema', itemIndex, '') as string;
const schemaType = this.getNodeParameter('schemaType', itemIndex) as 'fromJson' | 'manual';
const jsonSchema =
schemaType === 'fromJson'
? generateSchema(jsonExample)
: jsonParse<JSONSchema7>(inputSchema);
const zodSchema = convertJsonSchemaToZod<DynamicZodObject>(jsonSchema);
tool = new DynamicStructuredTool({
schema: zodSchema,
...functionBase,
});
} catch (error) {
throw new NodeOperationError(
this.getNode(),
'Error during parsing of JSON Schema. \n ' + error,
);
}
} else {
tool = new DynamicTool(functionBase);
}
return {
response: tool,
};
}
}

View file

@ -0,0 +1,345 @@
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
/* eslint-disable n8n-nodes-base/node-dirname-against-convention */
import type { INodeTypeDescription } from 'n8n-workflow';
import { NodeConnectionType } from 'n8n-workflow';
import {
inputSchemaField,
jsonSchemaExampleField,
schemaTypeField,
} from '../../../../utils/descriptions';
import { getConnectionHintNoticeField } from '../../../../utils/sharedFields';
export const versionDescription: INodeTypeDescription = {
displayName: 'Call n8n Workflow Tool',
name: 'toolWorkflow',
icon: 'fa:network-wired',
iconColor: 'black',
group: ['transform'],
version: [1, 1.1, 1.2, 1.3],
description: 'Uses another n8n workflow as a tool. Allows packaging any n8n node(s) as a tool.',
defaults: {
name: 'Call n8n Workflow Tool',
},
codex: {
categories: ['AI'],
subcategories: {
AI: ['Tools'],
Tools: ['Recommended Tools'],
},
resources: {
primaryDocumentation: [
{
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolworkflow/',
},
],
},
},
// eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node
inputs: [],
// eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong
outputs: [NodeConnectionType.AiTool],
outputNames: ['Tool'],
properties: [
getConnectionHintNoticeField([NodeConnectionType.AiAgent]),
{
displayName:
'See an example of a workflow to suggest meeting slots using AI <a href="/templates/1953" target="_blank">here</a>.',
name: 'noticeTemplateExample',
type: 'notice',
default: '',
},
{
displayName: 'Name',
name: 'name',
type: 'string',
default: '',
placeholder: 'My_Color_Tool',
displayOptions: {
show: {
'@version': [1],
},
},
},
{
displayName: 'Name',
name: 'name',
type: 'string',
default: '',
placeholder: 'e.g. My_Color_Tool',
validateType: 'string-alphanumeric',
description:
'The name of the function to be called, could contain letters, numbers, and underscores only',
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.1 } }],
},
},
},
{
displayName: 'Description',
name: 'description',
type: 'string',
default: '',
placeholder:
'Call this tool to get a random color. The input should be a string with comma separted names of colors to exclude.',
typeOptions: {
rows: 3,
},
},
{
displayName:
'This tool will call the workflow you define below, and look in the last node for the response. The workflow needs to start with an Execute Workflow trigger',
name: 'executeNotice',
type: 'notice',
default: '',
},
{
displayName: 'Source',
name: 'source',
type: 'options',
options: [
{
name: 'Database',
value: 'database',
description: 'Load the workflow from the database by ID',
},
{
name: 'Define Below',
value: 'parameter',
description: 'Pass the JSON code of a workflow',
},
],
default: 'database',
description: 'Where to get the workflow to execute from',
},
// ----------------------------------
// source:database
// ----------------------------------
{
displayName: 'Workflow ID',
name: 'workflowId',
type: 'string',
displayOptions: {
show: {
source: ['database'],
'@version': [{ _cnd: { lte: 1.1 } }],
},
},
default: '',
required: true,
description: 'The workflow to execute',
hint: 'Can be found in the URL of the workflow',
},
{
displayName: 'Workflow',
name: 'workflowId',
type: 'workflowSelector',
displayOptions: {
show: {
source: ['database'],
'@version': [{ _cnd: { gte: 1.2 } }],
},
},
default: '',
required: true,
},
// ----------------------------------
// source:parameter
// ----------------------------------
{
displayName: 'Workflow JSON',
name: 'workflowJson',
type: 'json',
typeOptions: {
rows: 10,
},
displayOptions: {
show: {
source: ['parameter'],
},
},
default: '\n\n\n\n\n\n\n\n\n',
required: true,
description: 'The workflow JSON code to execute',
},
// ----------------------------------
// For all
// ----------------------------------
{
displayName: 'Field to Return',
name: 'responsePropertyName',
type: 'string',
default: 'response',
required: true,
hint: 'The field in the last-executed node of the workflow that contains the response',
description:
'Where to find the data that this tool should return. n8n will look in the output of the last-executed node of the workflow for a field with this name, and return its value.',
displayOptions: {
show: {
'@version': [{ _cnd: { lt: 1.3 } }],
},
},
},
{
displayName: 'Extra Workflow Inputs',
name: 'fields',
placeholder: 'Add Value',
type: 'fixedCollection',
description:
"These will be output by the 'execute workflow' trigger of the workflow being called",
typeOptions: {
multipleValues: true,
sortable: true,
},
default: {},
options: [
{
name: 'values',
displayName: 'Values',
values: [
{
displayName: 'Name',
name: 'name',
type: 'string',
default: '',
placeholder: 'e.g. fieldName',
description:
'Name of the field to set the value of. Supports dot-notation. Example: data.person[0].name.',
requiresDataPath: 'single',
},
{
displayName: 'Type',
name: 'type',
type: 'options',
description: 'The field value type',
// eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items
options: [
{
name: 'String',
value: 'stringValue',
},
{
name: 'Number',
value: 'numberValue',
},
{
name: 'Boolean',
value: 'booleanValue',
},
{
name: 'Array',
value: 'arrayValue',
},
{
name: 'Object',
value: 'objectValue',
},
],
default: 'stringValue',
},
{
displayName: 'Value',
name: 'stringValue',
type: 'string',
default: '',
displayOptions: {
show: {
type: ['stringValue'],
},
},
validateType: 'string',
ignoreValidationDuringExecution: true,
},
{
displayName: 'Value',
name: 'numberValue',
type: 'string',
default: '',
displayOptions: {
show: {
type: ['numberValue'],
},
},
validateType: 'number',
ignoreValidationDuringExecution: true,
},
{
displayName: 'Value',
name: 'booleanValue',
type: 'options',
default: 'true',
options: [
{
name: 'True',
value: 'true',
},
{
name: 'False',
value: 'false',
},
],
displayOptions: {
show: {
type: ['booleanValue'],
},
},
validateType: 'boolean',
ignoreValidationDuringExecution: true,
},
{
displayName: 'Value',
name: 'arrayValue',
type: 'string',
default: '',
placeholder: 'e.g. [ arrayItem1, arrayItem2, arrayItem3 ]',
displayOptions: {
show: {
type: ['arrayValue'],
},
},
validateType: 'array',
ignoreValidationDuringExecution: true,
},
{
displayName: 'Value',
name: 'objectValue',
type: 'json',
default: '={}',
typeOptions: {
rows: 2,
},
displayOptions: {
show: {
type: ['objectValue'],
},
},
validateType: 'object',
ignoreValidationDuringExecution: true,
},
],
},
],
},
// ----------------------------------
// Output Parsing
// ----------------------------------
{
displayName: 'Specify Input Schema',
name: 'specifyInputSchema',
type: 'boolean',
description:
'Whether to specify the schema for the function. This would require the LLM to provide the input in the correct format and would validate it against the schema.',
noDataExpression: true,
default: false,
},
{ ...schemaTypeField, displayOptions: { show: { specifyInputSchema: [true] } } },
jsonSchemaExampleField,
inputSchemaField,
],
};

View file

@ -0,0 +1,42 @@
import { loadWorkflowInputMappings } from 'n8n-nodes-base/dist/utils/workflowInputsResourceMapping/GenericFunctions';
import type {
INodeTypeBaseDescription,
ISupplyDataFunctions,
SupplyData,
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';
import { WorkflowToolService } from './utils/WorkflowToolService';
import { versionDescription } from './versionDescription';
export class ToolWorkflowV2 implements INodeType {
description: INodeTypeDescription;
constructor(baseDescription: INodeTypeBaseDescription) {
this.description = {
...baseDescription,
...versionDescription,
};
}
methods = {
localResourceMapping: {
loadWorkflowInputMappings,
},
};
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
const workflowToolService = new WorkflowToolService(this);
const name = this.getNodeParameter('name', itemIndex) as string;
const description = this.getNodeParameter('description', itemIndex) as string;
const tool = await workflowToolService.createTool({
name,
description,
itemIndex,
});
return { response: tool };
}
}

View file

@ -0,0 +1,235 @@
/* eslint-disable @typescript-eslint/dot-notation */ // Disabled to allow access to private methods
import { DynamicTool } from '@langchain/core/tools';
import { NodeOperationError } from 'n8n-workflow';
import type {
ISupplyDataFunctions,
INodeExecutionData,
IWorkflowDataProxyData,
ExecuteWorkflowData,
INode,
} from 'n8n-workflow';
import { WorkflowToolService } from './utils/WorkflowToolService';
// Mock ISupplyDataFunctions interface
function createMockContext(overrides?: Partial<ISupplyDataFunctions>): ISupplyDataFunctions {
return {
getNodeParameter: jest.fn(),
getWorkflowDataProxy: jest.fn(),
getNode: jest.fn(),
executeWorkflow: jest.fn(),
addInputData: jest.fn(),
addOutputData: jest.fn(),
getCredentials: jest.fn(),
getCredentialsProperties: jest.fn(),
getInputData: jest.fn(),
getMode: jest.fn(),
getRestApiUrl: jest.fn(),
getTimezone: jest.fn(),
getWorkflow: jest.fn(),
getWorkflowStaticData: jest.fn(),
logger: {
debug: jest.fn(),
error: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
},
...overrides,
} as ISupplyDataFunctions;
}
describe('WorkflowTool::WorkflowToolService', () => {
let context: ISupplyDataFunctions;
let service: WorkflowToolService;
beforeEach(() => {
// Prepare essential mocks
context = createMockContext();
jest.spyOn(context, 'getNode').mockReturnValue({
parameters: { workflowInputs: { schema: [] } },
} as unknown as INode);
service = new WorkflowToolService(context);
});
describe('createTool', () => {
it('should create a basic dynamic tool when schema is not used', async () => {
const toolParams = {
name: 'TestTool',
description: 'Test Description',
itemIndex: 0,
};
const result = await service.createTool(toolParams);
expect(result).toBeInstanceOf(DynamicTool);
expect(result).toHaveProperty('name', 'TestTool');
expect(result).toHaveProperty('description', 'Test Description');
});
it('should create a tool that can handle successful execution', async () => {
const toolParams = {
name: 'TestTool',
description: 'Test Description',
itemIndex: 0,
};
const TEST_RESPONSE = { msg: 'test response' };
const mockExecuteWorkflowResponse: ExecuteWorkflowData = {
data: [[{ json: TEST_RESPONSE }]],
executionId: 'test-execution',
};
jest.spyOn(context, 'executeWorkflow').mockResolvedValueOnce(mockExecuteWorkflowResponse);
jest.spyOn(context, 'addInputData').mockReturnValue({ index: 0 });
jest.spyOn(context, 'getNodeParameter').mockReturnValue('database');
jest.spyOn(context, 'getWorkflowDataProxy').mockReturnValue({
$execution: { id: 'exec-id' },
$workflow: { id: 'workflow-id' },
} as unknown as IWorkflowDataProxyData);
const tool = await service.createTool(toolParams);
const result = await tool.func('test query');
expect(result).toBe(JSON.stringify(TEST_RESPONSE, null, 2));
expect(context.addOutputData).toHaveBeenCalled();
});
it('should handle errors during tool execution', async () => {
const toolParams = {
name: 'TestTool',
description: 'Test Description',
itemIndex: 0,
};
jest
.spyOn(context, 'executeWorkflow')
.mockRejectedValueOnce(new Error('Workflow execution failed'));
jest.spyOn(context, 'addInputData').mockReturnValue({ index: 0 });
jest.spyOn(context, 'getNodeParameter').mockReturnValue('database');
const tool = await service.createTool(toolParams);
const result = await tool.func('test query');
expect(result).toContain('There was an error');
expect(context.addOutputData).toHaveBeenCalled();
});
});
describe('handleToolResponse', () => {
it('should handle number response', () => {
const result = service['handleToolResponse'](42);
expect(result).toBe('42');
});
it('should handle object response', () => {
const obj = { test: 'value' };
const result = service['handleToolResponse'](obj);
expect(result).toBe(JSON.stringify(obj, null, 2));
});
it('should handle string response', () => {
const result = service['handleToolResponse']('test response');
expect(result).toBe('test response');
});
it('should throw error for invalid response type', () => {
expect(() => service['handleToolResponse'](undefined)).toThrow(NodeOperationError);
});
});
describe('executeSubWorkflow', () => {
it('should successfully execute workflow and return response', async () => {
const workflowInfo = { id: 'test-workflow' };
const items: INodeExecutionData[] = [];
const workflowProxyMock = {
$execution: { id: 'exec-id' },
$workflow: { id: 'workflow-id' },
} as unknown as IWorkflowDataProxyData;
const TEST_RESPONSE = { msg: 'test response' };
const mockResponse: ExecuteWorkflowData = {
data: [[{ json: TEST_RESPONSE }]],
executionId: 'test-execution',
};
jest.spyOn(context, 'executeWorkflow').mockResolvedValueOnce(mockResponse);
const result = await service['executeSubWorkflow'](workflowInfo, items, workflowProxyMock);
expect(result.response).toBe(TEST_RESPONSE);
expect(result.subExecutionId).toBe('test-execution');
});
it('should throw error when workflow execution fails', async () => {
jest.spyOn(context, 'executeWorkflow').mockRejectedValueOnce(new Error('Execution failed'));
await expect(service['executeSubWorkflow']({}, [], {} as never)).rejects.toThrow(
NodeOperationError,
);
});
it('should throw error when workflow returns no response', async () => {
const mockResponse: ExecuteWorkflowData = {
data: [],
executionId: 'test-execution',
};
jest.spyOn(context, 'executeWorkflow').mockResolvedValueOnce(mockResponse);
await expect(service['executeSubWorkflow']({}, [], {} as never)).rejects.toThrow();
});
});
describe('getSubWorkflowInfo', () => {
it('should handle database source correctly', async () => {
const source = 'database';
const itemIndex = 0;
const workflowProxyMock = {
$workflow: { id: 'proxy-id' },
} as unknown as IWorkflowDataProxyData;
jest.spyOn(context, 'getNodeParameter').mockReturnValueOnce({ value: 'workflow-id' });
const result = await service['getSubWorkflowInfo'](source, itemIndex, workflowProxyMock);
expect(result.workflowInfo).toHaveProperty('id', 'workflow-id');
expect(result.subWorkflowId).toBe('workflow-id');
});
it('should handle parameter source correctly', async () => {
const source = 'parameter';
const itemIndex = 0;
const workflowProxyMock = {
$workflow: { id: 'proxy-id' },
} as unknown as IWorkflowDataProxyData;
const mockWorkflow = { id: 'test-workflow' };
jest.spyOn(context, 'getNodeParameter').mockReturnValueOnce(JSON.stringify(mockWorkflow));
const result = await service['getSubWorkflowInfo'](source, itemIndex, workflowProxyMock);
expect(result.workflowInfo.code).toEqual(mockWorkflow);
expect(result.subWorkflowId).toBe('proxy-id');
});
it('should throw error for invalid JSON in parameter source', async () => {
const source = 'parameter';
const itemIndex = 0;
const workflowProxyMock = {
$workflow: { id: 'proxy-id' },
} as unknown as IWorkflowDataProxyData;
jest.spyOn(context, 'getNodeParameter').mockReturnValueOnce('invalid json');
await expect(
service['getSubWorkflowInfo'](source, itemIndex, workflowProxyMock),
).rejects.toThrow(NodeOperationError);
});
});
});

View file

@ -0,0 +1,284 @@
import type { ISupplyDataFunctions } from 'n8n-workflow';
import { jsonParse, NodeOperationError } from 'n8n-workflow';
import { z } from 'zod';
type AllowedTypes = 'string' | 'number' | 'boolean' | 'json';
export interface FromAIArgument {
key: string;
description?: string;
type?: AllowedTypes;
defaultValue?: string | number | boolean | Record<string, unknown>;
}
// TODO: We copied this class from the core package, once the new nodes context work is merged, this should be available in root node context and this file can be removed.
// Please apply any changes to both files
/**
* AIParametersParser
*
* This class encapsulates the logic for parsing node parameters, extracting $fromAI calls,
* generating Zod schemas, and creating LangChain tools.
*/
export class AIParametersParser {
private ctx: ISupplyDataFunctions;
/**
* Constructs an instance of AIParametersParser.
* @param ctx The execution context.
*/
constructor(ctx: ISupplyDataFunctions) {
this.ctx = ctx;
}
/**
* Generates a Zod schema based on the provided FromAIArgument placeholder.
* @param placeholder The FromAIArgument object containing key, type, description, and defaultValue.
* @returns A Zod schema corresponding to the placeholder's type and constraints.
*/
generateZodSchema(placeholder: FromAIArgument): z.ZodTypeAny {
let schema: z.ZodTypeAny;
switch (placeholder.type?.toLowerCase()) {
case 'string':
schema = z.string();
break;
case 'number':
schema = z.number();
break;
case 'boolean':
schema = z.boolean();
break;
case 'json':
schema = z.record(z.any());
break;
default:
schema = z.string();
}
if (placeholder.description) {
schema = schema.describe(`${schema.description ?? ''} ${placeholder.description}`.trim());
}
if (placeholder.defaultValue !== undefined) {
schema = schema.default(placeholder.defaultValue);
}
return schema;
}
/**
* Recursively traverses the nodeParameters object to find all $fromAI calls.
* @param payload The current object or value being traversed.
* @param collectedArgs The array collecting FromAIArgument objects.
*/
traverseNodeParameters(payload: unknown, collectedArgs: FromAIArgument[]) {
if (typeof payload === 'string') {
const fromAICalls = this.extractFromAICalls(payload);
fromAICalls.forEach((call) => collectedArgs.push(call));
} else if (Array.isArray(payload)) {
payload.forEach((item: unknown) => this.traverseNodeParameters(item, collectedArgs));
} else if (typeof payload === 'object' && payload !== null) {
Object.values(payload).forEach((value) => this.traverseNodeParameters(value, collectedArgs));
}
}
/**
* Extracts all $fromAI calls from a given string
* @param str The string to search for $fromAI calls.
* @returns An array of FromAIArgument objects.
*
* This method uses a regular expression to find the start of each $fromAI function call
* in the input string. It then employs a character-by-character parsing approach to
* accurately extract the arguments of each call, handling nested parentheses and quoted strings.
*
* The parsing process:
* 1. Finds the starting position of a $fromAI call using regex.
* 2. Iterates through characters, keeping track of parentheses depth and quote status.
* 3. Handles escaped characters within quotes to avoid premature quote closing.
* 4. Builds the argument string until the matching closing parenthesis is found.
* 5. Parses the extracted argument string into a FromAIArgument object.
* 6. Repeats the process for all $fromAI calls in the input string.
*
*/
extractFromAICalls(str: string): FromAIArgument[] {
const args: FromAIArgument[] = [];
// Regular expression to match the start of a $fromAI function call
const pattern = /\$fromAI\s*\(\s*/gi;
let match: RegExpExecArray | null;
while ((match = pattern.exec(str)) !== null) {
const startIndex = match.index + match[0].length;
let current = startIndex;
let inQuotes = false;
let quoteChar = '';
let parenthesesCount = 1;
let argsString = '';
// Parse the arguments string, handling nested parentheses and quotes
while (current < str.length && parenthesesCount > 0) {
const char = str[current];
if (inQuotes) {
// Handle characters inside quotes, including escaped characters
if (char === '\\' && current + 1 < str.length) {
argsString += char + str[current + 1];
current += 2;
continue;
}
if (char === quoteChar) {
inQuotes = false;
quoteChar = '';
}
argsString += char;
} else {
// Handle characters outside quotes
if (['"', "'", '`'].includes(char)) {
inQuotes = true;
quoteChar = char;
} else if (char === '(') {
parenthesesCount++;
} else if (char === ')') {
parenthesesCount--;
}
// Only add characters if we're still inside the main parentheses
if (parenthesesCount > 0 || char !== ')') {
argsString += char;
}
}
current++;
}
// If parentheses are balanced, parse the arguments
if (parenthesesCount === 0) {
try {
const parsedArgs = this.parseArguments(argsString);
args.push(parsedArgs);
} catch (error) {
// If parsing fails, throw an ApplicationError with details
throw new NodeOperationError(
this.ctx.getNode(),
`Failed to parse $fromAI arguments: ${argsString}: ${error}`,
);
}
} else {
// Log an error if parentheses are unbalanced
throw new NodeOperationError(
this.ctx.getNode(),
`Unbalanced parentheses while parsing $fromAI call: ${str.slice(startIndex)}`,
);
}
}
return args;
}
/**
* Parses the arguments of a single $fromAI function call.
* @param argsString The string containing the function arguments.
* @returns A FromAIArgument object.
*/
parseArguments(argsString: string): FromAIArgument {
// Split arguments by commas not inside quotes
const args: string[] = [];
let currentArg = '';
let inQuotes = false;
let quoteChar = '';
let escapeNext = false;
for (let i = 0; i < argsString.length; i++) {
const char = argsString[i];
if (escapeNext) {
currentArg += char;
escapeNext = false;
continue;
}
if (char === '\\') {
escapeNext = true;
continue;
}
if (['"', "'", '`'].includes(char)) {
if (!inQuotes) {
inQuotes = true;
quoteChar = char;
currentArg += char;
} else if (char === quoteChar) {
inQuotes = false;
quoteChar = '';
currentArg += char;
} else {
currentArg += char;
}
continue;
}
if (char === ',' && !inQuotes) {
args.push(currentArg.trim());
currentArg = '';
continue;
}
currentArg += char;
}
if (currentArg) {
args.push(currentArg.trim());
}
// Remove surrounding quotes if present
const cleanArgs = args.map((arg) => {
const trimmed = arg.trim();
if (
(trimmed.startsWith("'") && trimmed.endsWith("'")) ||
(trimmed.startsWith('`') && trimmed.endsWith('`')) ||
(trimmed.startsWith('"') && trimmed.endsWith('"'))
) {
return trimmed
.slice(1, -1)
.replace(/\\'/g, "'")
.replace(/\\`/g, '`')
.replace(/\\"/g, '"')
.replace(/\\\\/g, '\\');
}
return trimmed;
});
const type = cleanArgs?.[2] || 'string';
if (!['string', 'number', 'boolean', 'json'].includes(type.toLowerCase())) {
throw new NodeOperationError(this.ctx.getNode(), `Invalid type: ${type}`);
}
return {
key: cleanArgs[0] || '',
description: cleanArgs[1],
type: (cleanArgs?.[2] ?? 'string') as AllowedTypes,
defaultValue: this.parseDefaultValue(cleanArgs[3]),
};
}
/**
* Parses the default value, preserving its original type.
* @param value The default value as a string.
* @returns The parsed default value in its appropriate type.
*/
parseDefaultValue(
value: string | undefined,
): string | number | boolean | Record<string, unknown> | undefined {
if (value === undefined || value === '') return undefined;
const lowerValue = value.toLowerCase();
if (lowerValue === 'true') return true;
if (lowerValue === 'false') return false;
if (!isNaN(Number(value))) return Number(value);
try {
return jsonParse(value);
} catch {
return value;
}
}
}

View file

@ -0,0 +1,313 @@
import type { CallbackManagerForToolRun } from '@langchain/core/callbacks/manager';
import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools';
import get from 'lodash/get';
import isObject from 'lodash/isObject';
import type { SetField, SetNodeOptions } from 'n8n-nodes-base/dist/nodes/Set/v2/helpers/interfaces';
import * as manual from 'n8n-nodes-base/dist/nodes/Set/v2/manual.mode';
import { getCurrentWorkflowInputData } from 'n8n-nodes-base/dist/utils/workflowInputsResourceMapping/GenericFunctions';
import type {
ExecuteWorkflowData,
ExecutionError,
IDataObject,
IExecuteWorkflowInfo,
INodeExecutionData,
INodeParameterResourceLocator,
ISupplyDataFunctions,
ITaskMetadata,
IWorkflowBase,
IWorkflowDataProxyData,
ResourceMapperValue,
} from 'n8n-workflow';
import { jsonParse, NodeConnectionType, NodeOperationError } from 'n8n-workflow';
import { z } from 'zod';
import type { FromAIArgument } from './FromAIParser';
import { AIParametersParser } from './FromAIParser';
/**
Main class for creating the Workflow tool
Processes the node parameters and creates AI Agent tool capable of executing n8n workflows
*/
export class WorkflowToolService {
// Determines if we should use input schema when creating the tool
private useSchema: boolean;
// Sub-workflow id, pulled from referenced sub-workflow
private subWorkflowId: string | undefined;
// Sub-workflow execution id, will be set after the sub-workflow is executed
private subExecutionId: string | undefined;
constructor(private context: ISupplyDataFunctions) {
const subWorkflowInputs = this.context.getNode().parameters
.workflowInputs as ResourceMapperValue;
this.useSchema = (subWorkflowInputs?.schema ?? []).length > 0;
}
// Creates the tool based on the provided parameters
async createTool({
name,
description,
itemIndex,
}: {
name: string;
description: string;
itemIndex: number;
}): Promise<DynamicTool | DynamicStructuredTool> {
// Handler for the tool execution, will be called when the tool is executed
// This function will execute the sub-workflow and return the response
const toolHandler = async (
query: string | IDataObject,
runManager?: CallbackManagerForToolRun,
): Promise<string> => {
const { index } = this.context.addInputData(NodeConnectionType.AiTool, [
[{ json: { query } }],
]);
try {
const response = await this.runFunction(query, itemIndex, runManager);
const processedResponse = this.handleToolResponse(response);
// Once the sub-workflow is executed, add the output data to the context
// This will be used to link the sub-workflow execution in the parent workflow
let metadata: ITaskMetadata | undefined;
if (this.subExecutionId && this.subWorkflowId) {
metadata = {
subExecution: {
executionId: this.subExecutionId,
workflowId: this.subWorkflowId,
},
};
}
const json = jsonParse<IDataObject>(processedResponse, {
fallbackValue: { response: processedResponse },
});
void this.context.addOutputData(NodeConnectionType.AiTool, index, [[{ json }]], metadata);
return processedResponse;
} catch (error) {
const executionError = error as ExecutionError;
const errorResponse = `There was an error: "${executionError.message}"`;
void this.context.addOutputData(NodeConnectionType.AiTool, index, executionError);
return errorResponse;
}
};
// Create structured tool if input schema is provided
return this.useSchema
? await this.createStructuredTool(name, description, toolHandler)
: new DynamicTool({ name, description, func: toolHandler });
}
private handleToolResponse(response: unknown): string {
if (typeof response === 'number') {
return response.toString();
}
if (isObject(response)) {
return JSON.stringify(response, null, 2);
}
if (typeof response !== 'string') {
throw new NodeOperationError(this.context.getNode(), 'Wrong output type returned', {
description: `The response property should be a string, but it is an ${typeof response}`,
});
}
return response;
}
/**
* Executes specified sub-workflow with provided inputs
*/
private async executeSubWorkflow(
workflowInfo: IExecuteWorkflowInfo,
items: INodeExecutionData[],
workflowProxy: IWorkflowDataProxyData,
runManager?: CallbackManagerForToolRun,
): Promise<{ response: string; subExecutionId: string }> {
let receivedData: ExecuteWorkflowData;
try {
receivedData = await this.context.executeWorkflow(
workflowInfo,
items,
runManager?.getChild(),
{
parentExecution: {
executionId: workflowProxy.$execution.id,
workflowId: workflowProxy.$workflow.id,
},
},
);
// Set sub-workflow execution id so it can be used in other places
this.subExecutionId = receivedData.executionId;
} catch (error) {
throw new NodeOperationError(this.context.getNode(), error as Error);
}
const response: string | undefined = get(receivedData, 'data[0][0].json') as string | undefined;
if (response === undefined) {
throw new NodeOperationError(
this.context.getNode(),
'There was an error: "The workflow did not return a response"',
);
}
return { response, subExecutionId: receivedData.executionId };
}
/**
* Gets the sub-workflow info based on the source and executes it.
* This function will be called as part of the tool execution (from the toolHandler)
*/
private async runFunction(
query: string | IDataObject,
itemIndex: number,
runManager?: CallbackManagerForToolRun,
): Promise<string> {
const source = this.context.getNodeParameter('source', itemIndex) as string;
const workflowProxy = this.context.getWorkflowDataProxy(0);
const { workflowInfo } = await this.getSubWorkflowInfo(source, itemIndex, workflowProxy);
const rawData = this.prepareRawData(query, itemIndex);
const items = await this.prepareWorkflowItems(query, itemIndex, rawData);
this.subWorkflowId = workflowInfo.id;
const { response } = await this.executeSubWorkflow(
workflowInfo,
items,
workflowProxy,
runManager,
);
return response;
}
/**
* Gets the sub-workflow info based on the source (database or parameter)
*/
private async getSubWorkflowInfo(
source: string,
itemIndex: number,
workflowProxy: IWorkflowDataProxyData,
): Promise<{
workflowInfo: IExecuteWorkflowInfo;
subWorkflowId: string;
}> {
const workflowInfo: IExecuteWorkflowInfo = {};
let subWorkflowId: string;
if (source === 'database') {
const { value } = this.context.getNodeParameter(
'workflowId',
itemIndex,
{},
) as INodeParameterResourceLocator;
workflowInfo.id = value as string;
subWorkflowId = workflowInfo.id;
} else if (source === 'parameter') {
const workflowJson = this.context.getNodeParameter('workflowJson', itemIndex) as string;
try {
workflowInfo.code = JSON.parse(workflowJson) as IWorkflowBase;
// subworkflow is same as parent workflow
subWorkflowId = workflowProxy.$workflow.id;
} catch (error) {
throw new NodeOperationError(
this.context.getNode(),
`The provided workflow is not valid JSON: "${(error as Error).message}"`,
{ itemIndex },
);
}
}
return { workflowInfo, subWorkflowId: subWorkflowId! };
}
private prepareRawData(query: string | IDataObject, itemIndex: number): IDataObject {
const rawData: IDataObject = { query };
const workflowFieldsJson = this.context.getNodeParameter('fields.values', itemIndex, [], {
rawExpressions: true,
}) as SetField[];
// Copied from Set Node v2
for (const entry of workflowFieldsJson) {
if (entry.type === 'objectValue' && (entry.objectValue as string).startsWith('=')) {
rawData[entry.name] = (entry.objectValue as string).replace(/^=+/, '');
}
}
return rawData;
}
/**
* Prepares the sub-workflow items for execution
*/
private async prepareWorkflowItems(
query: string | IDataObject,
itemIndex: number,
rawData: IDataObject,
): Promise<INodeExecutionData[]> {
const options: SetNodeOptions = { include: 'all' };
let jsonData = typeof query === 'object' ? query : { query };
if (this.useSchema) {
const currentWorkflowInputs = getCurrentWorkflowInputData.call(this.context);
jsonData = currentWorkflowInputs[itemIndex].json;
}
const newItem = await manual.execute.call(
this.context,
{ json: jsonData },
itemIndex,
options,
rawData,
this.context.getNode(),
);
return [newItem] as INodeExecutionData[];
}
/**
* Create structured tool by parsing the sub-workflow input schema
*/
private async createStructuredTool(
name: string,
description: string,
func: (query: string | IDataObject, runManager?: CallbackManagerForToolRun) => Promise<string>,
): Promise<DynamicStructuredTool | DynamicTool> {
const fromAIParser = new AIParametersParser(this.context);
const collectedArguments = await this.extractFromAIParameters(fromAIParser);
// If there are no `fromAI` arguments, fallback to creating a simple tool
if (collectedArguments.length === 0) {
return new DynamicTool({ name, description, func });
}
// Otherwise, prepare Zod schema and create a structured tool
const schema = this.createZodSchema(collectedArguments, fromAIParser);
return new DynamicStructuredTool({ schema, name, description, func });
}
private async extractFromAIParameters(
fromAIParser: AIParametersParser,
): Promise<FromAIArgument[]> {
const collectedArguments: FromAIArgument[] = [];
fromAIParser.traverseNodeParameters(this.context.getNode().parameters, collectedArguments);
const uniqueArgsMap = new Map<string, FromAIArgument>();
for (const arg of collectedArguments) {
uniqueArgsMap.set(arg.key, arg);
}
return Array.from(uniqueArgsMap.values());
}
private createZodSchema(args: FromAIArgument[], parser: AIParametersParser): z.ZodObject<any> {
const schemaObj = args.reduce((acc: Record<string, z.ZodTypeAny>, placeholder) => {
acc[placeholder.key] = parser.generateZodSchema(placeholder);
return acc;
}, {});
return z.object(schemaObj).required();
}
}

View file

@ -0,0 +1,151 @@
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
/* eslint-disable n8n-nodes-base/node-dirname-against-convention */
import { NodeConnectionType, type INodeTypeDescription } from 'n8n-workflow';
import { getConnectionHintNoticeField } from '../../../../utils/sharedFields';
export const versionDescription: INodeTypeDescription = {
displayName: 'Call n8n Workflow Tool',
name: 'toolWorkflow',
icon: 'fa:network-wired',
group: ['transform'],
description: 'Uses another n8n workflow as a tool. Allows packaging any n8n node(s) as a tool.',
defaults: {
name: 'Call n8n Workflow Tool',
},
version: [2],
inputs: [],
outputs: [NodeConnectionType.AiTool],
outputNames: ['Tool'],
properties: [
getConnectionHintNoticeField([NodeConnectionType.AiAgent]),
{
displayName:
'See an example of a workflow to suggest meeting slots using AI <a href="/templates/1953" target="_blank">here</a>.',
name: 'noticeTemplateExample',
type: 'notice',
default: '',
},
{
displayName: 'Name',
name: 'name',
type: 'string',
default: '',
placeholder: 'e.g. My_Color_Tool',
validateType: 'string-alphanumeric',
description:
'The name of the function to be called, could contain letters, numbers, and underscores only',
},
{
displayName: 'Description',
name: 'description',
type: 'string',
default: '',
placeholder:
'Call this tool to get a random color. The input should be a string with comma separated names of colors to exclude.',
typeOptions: {
rows: 3,
},
},
{
displayName:
'This tool will call the workflow you define below, and look in the last node for the response. The workflow needs to start with an Execute Workflow trigger',
name: 'executeNotice',
type: 'notice',
default: '',
},
{
displayName: 'Source',
name: 'source',
type: 'options',
options: [
{
name: 'Database',
value: 'database',
description: 'Load the workflow from the database by ID',
},
{
name: 'Define Below',
value: 'parameter',
description: 'Pass the JSON code of a workflow',
},
],
default: 'database',
description: 'Where to get the workflow to execute from',
},
// ----------------------------------
// source:database
// ----------------------------------
{
displayName: 'Workflow',
name: 'workflowId',
type: 'workflowSelector',
displayOptions: {
show: {
source: ['database'],
},
},
default: '',
required: true,
},
// -----------------------------------------------
// Resource mapper for workflow inputs
// -----------------------------------------------
{
displayName: 'Workflow Inputs',
name: 'workflowInputs',
type: 'resourceMapper',
noDataExpression: true,
default: {
mappingMode: 'defineBelow',
value: null,
},
required: true,
typeOptions: {
loadOptionsDependsOn: ['workflowId.value'],
resourceMapper: {
localResourceMapperMethod: 'loadWorkflowInputMappings',
valuesLabel: 'Workflow Inputs',
mode: 'map',
fieldWords: {
singular: 'workflow input',
plural: 'workflow inputs',
},
addAllFields: true,
multiKeyMatch: false,
supportAutoMap: false,
},
},
displayOptions: {
show: {
source: ['database'],
},
hide: {
workflowId: [''],
},
},
},
// ----------------------------------
// source:parameter
// ----------------------------------
{
displayName: 'Workflow JSON',
name: 'workflowJson',
type: 'json',
typeOptions: {
rows: 10,
},
displayOptions: {
show: {
source: ['parameter'],
},
},
default: '\n\n\n\n\n\n\n\n\n',
required: true,
description: 'The workflow JSON code to execute',
},
],
};

View file

@ -93,6 +93,22 @@ export class DynamicNodeParametersController {
);
}
@Post('/local-resource-mapper-fields')
async getLocalResourceMappingFields(req: DynamicNodeParametersRequest.ResourceMapperFields) {
const { path, methodName, currentNodeParameters, nodeTypeAndVersion } = req.body;
if (!methodName) throw new BadRequestError('Missing `methodName` in request body');
const additionalData = await getBase(req.user.id, currentNodeParameters);
return await this.service.getLocalResourceMappingFields(
methodName,
path,
additionalData,
nodeTypeAndVersion,
);
}
@Post('/action-result')
async getActionResult(
req: DynamicNodeParametersRequest.ActionResult,

View file

@ -1,4 +1,4 @@
import { LoadOptionsContext, RoutingNode } from 'n8n-core';
import { LoadOptionsContext, RoutingNode, LocalLoadOptionsContext } from 'n8n-core';
import type {
ILoadOptions,
ILoadOptionsFunctions,
@ -17,15 +17,43 @@ import type {
INodeTypeNameVersion,
NodeParameterValueType,
IDataObject,
ILocalLoadOptionsFunctions,
} from 'n8n-workflow';
import { Workflow, ApplicationError } from 'n8n-workflow';
import { Service } from 'typedi';
import { NodeTypes } from '@/node-types';
import { WorkflowLoaderService } from './workflow-loader.service';
type LocalResourceMappingMethod = (
this: ILocalLoadOptionsFunctions,
) => Promise<ResourceMapperFields>;
type ListSearchMethod = (
this: ILoadOptionsFunctions,
filter?: string,
paginationToken?: string,
) => Promise<INodeListSearchResult>;
type LoadOptionsMethod = (this: ILoadOptionsFunctions) => Promise<INodePropertyOptions[]>;
type ActionHandlerMethod = (
this: ILoadOptionsFunctions,
payload?: string,
) => Promise<NodeParameterValueType>;
type ResourceMappingMethod = (this: ILoadOptionsFunctions) => Promise<ResourceMapperFields>;
type NodeMethod =
| LocalResourceMappingMethod
| ListSearchMethod
| LoadOptionsMethod
| ActionHandlerMethod
| ResourceMappingMethod;
@Service()
export class DynamicNodeParametersService {
constructor(private nodeTypes: NodeTypes) {}
constructor(
private nodeTypes: NodeTypes,
private workflowLoaderService: WorkflowLoaderService,
) {}
/** Returns the available options via a predefined method */
async getOptionsViaMethodName(
@ -40,6 +68,8 @@ export class DynamicNodeParametersService {
const method = this.getMethod('loadOptions', methodName, nodeType);
const workflow = this.getWorkflow(nodeTypeAndVersion, currentNodeParameters, credentials);
const thisArgs = this.getThisArg(path, additionalData, workflow);
// Need to use untyped call since `this` usage is widespread and we don't have `strictBindCallApply`
// enabled in `tsconfig.json`
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return method.call(thisArgs);
}
@ -157,6 +187,20 @@ export class DynamicNodeParametersService {
return method.call(thisArgs);
}
/** Returns the available workflow input mapping fields for the ResourceMapper component */
async getLocalResourceMappingFields(
methodName: string,
path: string,
additionalData: IWorkflowExecuteAdditionalData,
nodeTypeAndVersion: INodeTypeNameVersion,
): Promise<ResourceMapperFields> {
const nodeType = this.getNodeType(nodeTypeAndVersion);
const method = this.getMethod('localResourceMapping', methodName, nodeType);
const thisArgs = this.getLocalLoadOptionsContext(path, additionalData);
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return method.call(thisArgs);
}
/** Returns the result of the action handler */
async getActionResult(
handler: string,
@ -179,33 +223,34 @@ export class DynamicNodeParametersService {
type: 'resourceMapping',
methodName: string,
nodeType: INodeType,
): (this: ILoadOptionsFunctions) => Promise<ResourceMapperFields>;
): ResourceMappingMethod;
private getMethod(
type: 'listSearch',
type: 'localResourceMapping',
methodName: string,
nodeType: INodeType,
): (
this: ILoadOptionsFunctions,
filter?: string | undefined,
paginationToken?: string | undefined,
) => Promise<INodeListSearchResult>;
): LocalResourceMappingMethod;
private getMethod(type: 'listSearch', methodName: string, nodeType: INodeType): ListSearchMethod;
private getMethod(
type: 'loadOptions',
methodName: string,
nodeType: INodeType,
): (this: ILoadOptionsFunctions) => Promise<INodePropertyOptions[]>;
): LoadOptionsMethod;
private getMethod(
type: 'actionHandler',
methodName: string,
nodeType: INodeType,
): (this: ILoadOptionsFunctions, payload?: string) => Promise<NodeParameterValueType>;
): ActionHandlerMethod;
private getMethod(
type: 'resourceMapping' | 'listSearch' | 'loadOptions' | 'actionHandler',
type:
| 'resourceMapping'
| 'localResourceMapping'
| 'listSearch'
| 'loadOptions'
| 'actionHandler',
methodName: string,
nodeType: INodeType,
) {
const method = nodeType.methods?.[type]?.[methodName];
): NodeMethod {
const method = nodeType.methods?.[type]?.[methodName] as NodeMethod;
if (typeof method !== 'function') {
throw new ApplicationError('Node type does not have method defined', {
tags: { nodeType: nodeType.description.name },
@ -253,4 +298,16 @@ export class DynamicNodeParametersService {
const node = workflow.nodes['Temp-Node'];
return new LoadOptionsContext(workflow, node, additionalData, path);
}
private getLocalLoadOptionsContext(
path: string,
additionalData: IWorkflowExecuteAdditionalData,
): ILocalLoadOptionsFunctions {
return new LocalLoadOptionsContext(
this.nodeTypes,
additionalData,
path,
this.workflowLoaderService,
);
}
}

View file

@ -0,0 +1,19 @@
import { ApplicationError, type IWorkflowBase, type IWorkflowLoader } from 'n8n-workflow';
import { Service } from 'typedi';
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
@Service()
export class WorkflowLoaderService implements IWorkflowLoader {
constructor(private readonly workflowRepository: WorkflowRepository) {}
async get(workflowId: string): Promise<IWorkflowBase> {
const workflow = await this.workflowRepository.findById(workflowId);
if (!workflow) {
throw new ApplicationError(`Failed to find workflow with ID "${workflowId}"`);
}
return workflow;
}
}

View file

@ -17,6 +17,9 @@ type ParserOptions = {
handleToolInvocation: (toolArgs: IDataObject) => Promise<unknown>;
};
// This file is temporarily duplicated in `packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/FromAIParser.ts`
// Please apply any changes in both files
/**
* AIParametersParser
*

View file

@ -1197,7 +1197,7 @@ export class WorkflowExecute {
});
if (workflowIssues !== null) {
throw new WorkflowOperationError(
'The workflow has issues and can for that reason not be executed. Please fix them first.',
'The workflow has issues and cannot be executed for that reason. Please fix them first.',
);
}

View file

@ -3,6 +3,7 @@ export { ExecuteContext } from './execute-context';
export { ExecuteSingleContext } from './execute-single-context';
export { HookContext } from './hook-context';
export { LoadOptionsContext } from './load-options-context';
export { LocalLoadOptionsContext } from './local-load-options-context';
export { PollContext } from './poll-context';
// eslint-disable-next-line import/no-cycle
export { SupplyDataContext } from './supply-data-context';

View file

@ -0,0 +1,70 @@
import lodash from 'lodash';
import { ApplicationError, Workflow } from 'n8n-workflow';
import type {
INodeParameterResourceLocator,
IWorkflowExecuteAdditionalData,
NodeParameterValueType,
ILocalLoadOptionsFunctions,
IWorkflowLoader,
IWorkflowNodeContext,
INodeTypes,
} from 'n8n-workflow';
import { LoadWorkflowNodeContext } from './workflow-node-context';
export class LocalLoadOptionsContext implements ILocalLoadOptionsFunctions {
constructor(
private nodeTypes: INodeTypes,
private additionalData: IWorkflowExecuteAdditionalData,
private path: string,
private workflowLoader: IWorkflowLoader,
) {}
async getWorkflowNodeContext(nodeType: string): Promise<IWorkflowNodeContext | null> {
const { value: workflowId } = this.getCurrentNodeParameter(
'workflowId',
) as INodeParameterResourceLocator;
if (typeof workflowId !== 'string' || !workflowId) {
throw new ApplicationError(`No workflowId parameter defined on node of type "${nodeType}"!`);
}
const dbWorkflow = await this.workflowLoader.get(workflowId);
const selectedWorkflowNode = dbWorkflow.nodes.find((node) => node.type === nodeType);
if (selectedWorkflowNode) {
const selectedSingleNodeWorkflow = new Workflow({
nodes: [selectedWorkflowNode],
connections: {},
active: false,
nodeTypes: this.nodeTypes,
});
const workflowAdditionalData = {
...this.additionalData,
currentNodeParameters: selectedWorkflowNode.parameters,
};
return new LoadWorkflowNodeContext(
selectedSingleNodeWorkflow,
selectedWorkflowNode,
workflowAdditionalData,
);
}
return null;
}
getCurrentNodeParameter(parameterPath: string): NodeParameterValueType | object | undefined {
const nodeParameters = this.additionalData.currentNodeParameters;
if (parameterPath.startsWith('&')) {
parameterPath = `${this.path.split('.').slice(1, -1).join('.')}.${parameterPath.slice(1)}`;
}
const returnData = lodash.get(nodeParameters, parameterPath);
return returnData;
}
}

View file

@ -53,15 +53,19 @@ const validateResourceMapperValue = (
};
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (schemaEntry?.type) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const validationResult = validateFieldType(key, resolvedValue, schemaEntry.type, {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
valueOptions: schemaEntry.options,
strict: !resourceMapperField.attemptToConvertTypes,
parseStrings: !!resourceMapperField.convertFieldsToString,
});
if (!validationResult.valid) {
return { ...validationResult, fieldName: key };
if (!resourceMapperField.ignoreTypeMismatchErrors) {
return { ...validationResult, fieldName: key };
} else {
paramValues[key] = resolvedValue;
}
} else {
// If it's valid, set the casted value
paramValues[key] = validationResult.newValue;

View file

@ -0,0 +1,36 @@
import type {
IGetNodeParameterOptions,
INode,
IWorkflowExecuteAdditionalData,
Workflow,
IWorkflowNodeContext,
} from 'n8n-workflow';
import { NodeExecutionContext } from './node-execution-context';
export class LoadWorkflowNodeContext extends NodeExecutionContext implements IWorkflowNodeContext {
// Note that this differs from and does not shadow the function with the
// same name in `NodeExecutionContext`, as it has the `itemIndex` parameter
readonly getNodeParameter: IWorkflowNodeContext['getNodeParameter'];
constructor(workflow: Workflow, node: INode, additionalData: IWorkflowExecuteAdditionalData) {
super(workflow, node, additionalData, 'internal');
{
// We need to cast due to the overloaded IWorkflowNodeContext::getNodeParameter function
// Which would require us to replicate all overload return types, as TypeScript offers
// no convenient solution to refer to a set of overloads.
this.getNodeParameter = ((
parameterName: string,
itemIndex: number,
fallbackValue?: unknown,
options?: IGetNodeParameterOptions,
) =>
this._getNodeParameter(
parameterName,
itemIndex,
fallbackValue,
options,
)) as IWorkflowNodeContext['getNodeParameter'];
}
}
}

View file

@ -59,6 +59,18 @@ export async function getResourceMapperFields(
);
}
export async function getLocalResourceMapperFields(
context: IRestApiContext,
sendData: DynamicNodeParameters.ResourceMapperFieldsRequest,
): Promise<ResourceMapperFields> {
return await makeRestApiRequest(
context,
'POST',
'/dynamic-node-parameters/local-resource-mapper-fields',
sendData,
);
}
export async function getNodeParameterActionResult(
context: IRestApiContext,
sendData: DynamicNodeParameters.ActionResultRequest,

View file

@ -1431,6 +1431,7 @@ onUpdated(async () => {
:key="option.value.toString()"
:value="option.value"
:label="getOptionsOptionDisplayName(option)"
data-test-id="parameter-input-item"
>
<div class="list-option">
<div

View file

@ -74,6 +74,27 @@ describe('ResourceMapper.vue', () => {
expect(queryByTestId('matching-column-select')).not.toBeInTheDocument();
});
it('renders map mode properly', async () => {
const { getByTestId, queryByTestId } = renderComponent(
{
props: {
parameter: {
typeOptions: {
resourceMapper: {
mode: 'map',
},
},
},
},
},
{ merge: true },
);
await waitAllPromises();
expect(getByTestId('resource-mapper-container')).toBeInTheDocument();
// This mode doesn't render matching column selector
expect(queryByTestId('matching-column-select')).not.toBeInTheDocument();
});
it('renders multi-key match selector properly', async () => {
const { container, getByTestId } = renderComponent(
{
@ -201,7 +222,7 @@ describe('ResourceMapper.vue', () => {
expect(
getByText('Look for incoming data that matches the foos in the service'),
).toBeInTheDocument();
expect(getByText('Foos to Match On')).toBeInTheDocument();
expect(getByText('Foos to match on')).toBeInTheDocument();
expect(
getByText(
'The foos to use when matching rows in the service to the input items of this node. Usually an ID.',

View file

@ -21,7 +21,14 @@ import {
parseResourceMapperFieldName,
} from '@/utils/nodeTypesUtils';
import { useNodeSpecificationValues } from '@/composables/useNodeSpecificationValues';
import { N8nIconButton, N8nInputLabel, N8nOption, N8nSelect, N8nTooltip } from 'n8n-design-system';
import {
N8nIcon,
N8nIconButton,
N8nInputLabel,
N8nOption,
N8nSelect,
N8nTooltip,
} from 'n8n-design-system';
import { useI18n } from '@/composables/useI18n';
interface Props {
@ -37,11 +44,13 @@ interface Props {
refreshInProgress: boolean;
teleported?: boolean;
isReadOnly?: boolean;
isDataStale?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
teleported: true,
isReadOnly: false,
isDataStale: false,
});
const FORCE_TEXT_INPUT_FOR_TYPES: FieldType[] = ['time', 'object', 'array'];
@ -310,6 +319,27 @@ defineExpose({
:value="props.paramValue"
@update:model-value="onParameterActionSelected"
/>
<div v-if="props.isDataStale && !props.refreshInProgress" :class="$style.staleDataWarning">
<N8nTooltip>
<template #content>
<span>{{
locale.baseText('resourceMapper.staleDataWarning.tooltip', {
interpolate: { fieldWord: pluralFieldWordCapitalized },
})
}}</span>
</template>
<N8nIcon icon="exclamation-triangle" size="small" color="warning" />
</N8nTooltip>
<N8nIconButton
icon="refresh"
type="tertiary"
size="small"
:text="true"
:title="locale.baseText('generic.refresh')"
:disabled="props.refreshInProgress"
@click="onParameterActionSelected('refreshFieldList')"
/>
</div>
</template>
</N8nInputLabel>
<div v-if="orderedFields.length === 0" class="mt-3xs mb-xs">
@ -360,7 +390,7 @@ defineExpose({
:title="
locale.baseText('resourceMapper.removeField', {
interpolate: {
fieldWord: singularFieldWordCapitalized,
fieldWord: singularFieldWord,
},
})
"
@ -391,7 +421,7 @@ defineExpose({
<N8nSelect
:placeholder="
locale.baseText('resourceMapper.addFieldToSend', {
interpolate: { fieldWord: singularFieldWordCapitalized },
interpolate: { fieldWord: singularFieldWord },
})
"
size="small"
@ -442,4 +472,11 @@ defineExpose({
margin-top: var(--spacing-l);
padding: 0 0 0 var(--spacing-s);
}
.staleDataWarning {
display: flex;
height: var(--spacing-m);
align-items: baseline;
gap: var(--spacing-5xs);
}
</style>

View file

@ -7,7 +7,9 @@ import type {
INodeParameters,
INodeProperties,
INodeTypeDescription,
NodeParameterValueType,
ResourceMapperField,
ResourceMapperFields,
ResourceMapperValue,
} from 'n8n-workflow';
import { NodeHelpers } from 'n8n-workflow';
@ -15,11 +17,17 @@ import { computed, onMounted, reactive, watch } from 'vue';
import MappingModeSelect from './MappingModeSelect.vue';
import MatchingColumnsSelect from './MatchingColumnsSelect.vue';
import MappingFields from './MappingFields.vue';
import { fieldCannotBeDeleted, parseResourceMapperFieldName } from '@/utils/nodeTypesUtils';
import {
fieldCannotBeDeleted,
isResourceMapperFieldListStale,
parseResourceMapperFieldName,
} from '@/utils/nodeTypesUtils';
import { isFullExecutionResponse, isResourceMapperValue } from '@/utils/typeGuards';
import { i18n as locale } from '@/plugins/i18n';
import { useNDVStore } from '@/stores/ndv.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useDocumentVisibility } from '@/composables/useDocumentVisibility';
import { N8nButton, N8nCallout } from 'n8n-design-system';
type Props = {
parameter: INodeProperties;
@ -42,6 +50,8 @@ const props = withDefaults(defineProps<Props>(), {
isReadOnly: false,
});
const { onDocumentVisible } = useDocumentVisibility();
const emit = defineEmits<{
valueChanged: [value: IUpdateInformation];
}>();
@ -52,11 +62,18 @@ const state = reactive({
value: {},
matchingColumns: [] as string[],
schema: [] as ResourceMapperField[],
ignoreTypeMismatchErrors: false,
attemptToConvertTypes: false,
// This should always be true if `showTypeConversionOptions` is provided
// It's used to avoid accepting any value as string without casting it
// Which is the legacy behavior without these type options.
convertFieldsToString: false,
} as ResourceMapperValue,
parameterValues: {} as INodeParameters,
loading: false,
refreshInProgress: false, // Shows inline loader when refreshing fields
loadingError: false,
hasStaleFields: false,
});
// Reload fields to map when dependent parameters change
@ -76,6 +93,21 @@ watch(
},
);
onDocumentVisible(async () => {
await checkStaleFields();
});
async function checkStaleFields(): Promise<void> {
const fetchedFields = await fetchFields();
if (fetchedFields) {
const isSchemaStale = isResourceMapperFieldListStale(
state.paramValue.schema,
fetchedFields.fields,
);
state.hasStaleFields = isSchemaStale;
}
}
// Reload fields to map when node is executed
watch(
() => workflowsStore.getWorkflowExecution,
@ -97,6 +129,10 @@ onMounted(async () => {
...state.parameterValues,
parameters: props.node.parameters,
};
if (showTypeConversionOptions.value) {
state.paramValue.convertFieldsToString = true;
}
}
const params = state.parameterValues.parameters as INodeParameters;
const parameterName = props.parameter.name;
@ -138,6 +174,8 @@ onMounted(async () => {
if (!hasSchema) {
// Only fetch a schema if it's not already set
await initFetching();
} else {
await checkStaleFields();
}
// Set default values if this is the first time the parameter is being set
if (!state.paramValue.value) {
@ -161,11 +199,19 @@ const showMappingModeSelect = computed<boolean>(() => {
return props.parameter.typeOptions?.resourceMapper?.supportAutoMap !== false;
});
const showTypeConversionOptions = computed<boolean>(() => {
return props.parameter.typeOptions?.resourceMapper?.showTypeConversionOptions === true;
});
const hasFields = computed<boolean>(() => {
return state.paramValue.schema.length > 0;
});
const showMatchingColumnsSelector = computed<boolean>(() => {
return (
!state.loading &&
props.parameter.typeOptions?.resourceMapper?.mode !== 'add' &&
state.paramValue.schema.length > 0
['upsert', 'update'].includes(props.parameter.typeOptions?.resourceMapper?.mode ?? '') &&
hasFields.value
);
});
@ -174,7 +220,7 @@ const showMappingFields = computed<boolean>(() => {
state.paramValue.mappingMode === 'defineBelow' &&
!state.loading &&
!state.loadingError &&
state.paramValue.schema.length > 0 &&
hasFields.value &&
hasAvailableMatchingColumns.value
);
});
@ -190,6 +236,10 @@ const matchingColumns = computed<string[]>(() => {
});
const hasAvailableMatchingColumns = computed<boolean>(() => {
// 'map' mode doesn't require matching columns
if (resourceMapperMode.value === 'map') {
return true;
}
if (resourceMapperMode.value !== 'add' && resourceMapperMode.value !== 'upsert') {
return (
state.paramValue.schema.filter(
@ -227,10 +277,11 @@ async function initFetching(inlineLoading = false): Promise<void> {
state.loading = true;
}
try {
await loadFieldsToMap();
await loadAndSetFieldsToMap();
if (!state.paramValue.matchingColumns || state.paramValue.matchingColumns.length === 0) {
onMatchingColumnsChanged(defaultSelectedMatchingColumns.value);
}
state.hasStaleFields = false;
} catch (error) {
state.loadingError = true;
} finally {
@ -239,19 +290,13 @@ async function initFetching(inlineLoading = false): Promise<void> {
}
}
async function loadFieldsToMap(): Promise<void> {
const createRequestParams = (methodName: string) => {
if (!props.node) {
return;
}
const methodName = props.parameter.typeOptions?.resourceMapper?.resourceMapperMethod;
if (typeof methodName !== 'string') {
return;
}
const requestParams: DynamicNodeParameters.ResourceMapperFieldsRequest = {
nodeTypeAndVersion: {
name: props.node?.type,
name: props.node.type,
version: props.node.typeVersion,
},
currentNodeParameters: resolveRequiredParameters(
@ -262,7 +307,38 @@ async function loadFieldsToMap(): Promise<void> {
methodName,
credentials: props.node.credentials,
};
const fetchedFields = await nodeTypesStore.getResourceMapperFields(requestParams);
return requestParams;
};
async function fetchFields(): Promise<ResourceMapperFields | null> {
const { resourceMapperMethod, localResourceMapperMethod } =
props.parameter.typeOptions?.resourceMapper ?? {};
let fetchedFields = null;
if (typeof resourceMapperMethod === 'string') {
const requestParams = createRequestParams(
resourceMapperMethod,
) as DynamicNodeParameters.ResourceMapperFieldsRequest;
fetchedFields = await nodeTypesStore.getResourceMapperFields(requestParams);
} else if (typeof localResourceMapperMethod === 'string') {
const requestParams = createRequestParams(
localResourceMapperMethod,
) as DynamicNodeParameters.ResourceMapperFieldsRequest;
fetchedFields = await nodeTypesStore.getLocalResourceMapperFields(requestParams);
}
return fetchedFields;
}
async function loadAndSetFieldsToMap(): Promise<void> {
if (!props.node) {
return;
}
const fetchedFields = await fetchFields();
if (fetchedFields !== null) {
const newSchema = fetchedFields.fields.map((field) => {
const existingField = state.paramValue.schema.find((f) => f.id === field.id);
@ -531,11 +607,26 @@ defineExpose({
:teleported="teleported"
:refresh-in-progress="state.refreshInProgress"
:is-read-only="isReadOnly"
:is-data-stale="state.hasStaleFields"
@field-value-changed="fieldValueChanged"
@remove-field="removeField"
@add-field="addField"
@refresh-field-list="initFetching(true)"
/>
<N8nCallout v-else-if="state.hasStaleFields" theme="info" :iconless="true">
{{ locale.baseText('resourceMapper.staleDataWarning.notice') }}
<template #trailingContent>
<N8nButton
size="mini"
icon="refresh"
type="secondary"
:loading="state.refreshInProgress"
@click="initFetching(true)"
>
{{ locale.baseText('generic.refresh') }}
</N8nButton>
</template>
</N8nCallout>
<N8nNotice
v-if="state.paramValue.mappingMode === 'autoMapInputData' && hasAvailableMatchingColumns"
>
@ -548,5 +639,49 @@ defineExpose({
})
}}
</N8nNotice>
<div v-if="showTypeConversionOptions && hasFields" :class="$style.typeConversionOptions">
<ParameterInputFull
:parameter="{
name: 'attemptToConvertTypes',
type: 'boolean',
displayName: locale.baseText('resourceMapper.attemptToConvertTypes.displayName'),
default: false,
description: locale.baseText('resourceMapper.attemptToConvertTypes.description'),
}"
:path="props.path + '.attemptToConvertTypes'"
:value="state.paramValue.attemptToConvertTypes"
@update="
(x: IUpdateInformation<NodeParameterValueType>) => {
state.paramValue.attemptToConvertTypes = x.value as boolean;
emitValueChanged();
}
"
/>
<ParameterInputFull
:parameter="{
name: 'ignoreTypeMismatchErrors',
type: 'boolean',
displayName: locale.baseText('resourceMapper.ignoreTypeMismatchErrors.displayName'),
default: false,
description: locale.baseText('resourceMapper.ignoreTypeMismatchErrors.description'),
}"
:path="props.path + '.ignoreTypeMismatchErrors'"
:value="state.paramValue.ignoreTypeMismatchErrors"
@update="
(x: IUpdateInformation<NodeParameterValueType>) => {
state.paramValue.ignoreTypeMismatchErrors = x.value as boolean;
emitValueChanged();
}
"
/>
</div>
</div>
</template>
<style module lang="scss">
.typeConversionOptions {
display: grid;
padding: var(--spacing-m);
gap: var(--spacing-2xs);
}
</style>

View file

@ -0,0 +1,52 @@
import type { Ref } from 'vue';
import { ref, onMounted, onUnmounted } from 'vue';
type VisibilityHandler = () => void;
type DocumentVisibilityResult = {
isVisible: Ref<boolean>;
onDocumentVisible: (handler: VisibilityHandler) => void;
onDocumentHidden: (handler: VisibilityHandler) => void;
};
export function useDocumentVisibility(): DocumentVisibilityResult {
const isVisible = ref<boolean>(!document.hidden);
const visibleHandlers = ref<VisibilityHandler[]>([]);
const hiddenHandlers = ref<VisibilityHandler[]>([]);
const onVisibilityChange = (): void => {
const newVisibilityState = !document.hidden;
isVisible.value = newVisibilityState;
if (newVisibilityState) {
visibleHandlers.value.forEach((handler) => handler());
} else {
hiddenHandlers.value.forEach((handler) => handler());
}
};
const onDocumentVisible = (handler: VisibilityHandler): void => {
visibleHandlers.value.push(handler);
};
const onDocumentHidden = (handler: VisibilityHandler): void => {
hiddenHandlers.value.push(handler);
};
onMounted((): void => {
document.addEventListener('visibilitychange', onVisibilityChange);
});
onUnmounted((): void => {
document.removeEventListener('visibilitychange', onVisibilityChange);
// Clear handlers on unmount
visibleHandlers.value = [];
hiddenHandlers.value = [];
});
return {
isVisible,
onDocumentVisible,
onDocumentHidden,
};
}

View file

@ -175,7 +175,8 @@ export const SAMPLE_SUBWORKFLOW_WORKFLOW: WorkflowDataWithTemplateId = {
nodes: [
{
id: 'c055762a-8fe7-4141-a639-df2372f30060',
name: 'Execute Workflow Trigger',
typeVersion: 1.1,
name: 'Workflow Input Trigger',
type: 'n8n-nodes-base.executeWorkflowTrigger',
position: [260, 340],
parameters: {},
@ -189,7 +190,7 @@ export const SAMPLE_SUBWORKFLOW_WORKFLOW: WorkflowDataWithTemplateId = {
},
] as INodeUi[],
connections: {
'Execute Workflow Trigger': {
'Workflow Input Trigger': {
main: [
[
{

View file

@ -58,6 +58,7 @@
"generic.yes": "Yes",
"generic.no": "No",
"generic.rating": "Rating",
"generic.refresh": "Refresh",
"generic.retry": "Retry",
"generic.error": "Something went wrong",
"generic.settings": "Settings",
@ -1572,7 +1573,7 @@
"resourceMapper.fetchingFields.message": "Fetching {fieldWord}",
"resourceMapper.fetchingFields.errorMessage": "Can't get {fieldWord}.",
"resourceMapper.fetchingFields.noFieldsFound": "No {fieldWord} found in {serviceName}.",
"resourceMapper.columnsToMatchOn.label": "{fieldWord} to Match On",
"resourceMapper.columnsToMatchOn.label": "{fieldWord} to match on",
"resourceMapper.columnsToMatchOn.multi.description": "The {fieldWord} to use when matching rows in {nodeDisplayName} to the input items of this node. Usually an ID.",
"resourceMapper.columnsToMatchOn.single.description": "The {fieldWord} to use when matching rows in {nodeDisplayName} to the input items of this node. Usually an ID.",
"resourceMapper.columnsToMatchOn.tooltip": "The {fieldWord} to compare when finding the rows to update",
@ -1583,11 +1584,17 @@
"resourceMapper.usingToMatch.description": "This {fieldWord} won't be updated and can't be removed, as it's used for matching",
"resourceMapper.removeField": "Remove {fieldWord}",
"resourceMapper.mandatoryField.title": "This {fieldWord} is mandatory and cant be removed",
"resourceMapper.addFieldToSend": "Add {fieldWord} to Send",
"resourceMapper.addFieldToSend": "Add {fieldWord} to send",
"resourceMapper.matching.title": "This {fieldWord} is used for matching and cant be removed",
"resourceMapper.addAllFields": "Add All {fieldWord}",
"resourceMapper.removeAllFields": "Remove All {fieldWord}",
"resourceMapper.refreshFieldList": "Refresh {fieldWord} List",
"resourceMapper.staleDataWarning.tooltip": "{fieldWord} are outdated. Refresh to see the changes.",
"resourceMapper.staleDataWarning.notice": "Refresh to see the updated fields",
"resourceMapper.attemptToConvertTypes.displayName": "Attempt to convert types",
"resourceMapper.attemptToConvertTypes.description": "Attempt to convert types when mapping fields",
"resourceMapper.ignoreTypeMismatchErrors.displayName": "Ignore type mismatch errors",
"resourceMapper.ignoreTypeMismatchErrors.description": "Whether type mismatches should be ignored, rather than returning an Error",
"runData.openSubExecution": "Inspect Sub-Execution {id}",
"runData.openParentExecution": "Inspect Parent Execution {id}",
"runData.emptyItemHint": "This is an item, but it's empty.",

View file

@ -302,6 +302,16 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => {
}
};
const getLocalResourceMapperFields = async (
sendData: DynamicNodeParameters.ResourceMapperFieldsRequest,
) => {
try {
return await nodeTypesApi.getLocalResourceMapperFields(rootStore.restApiContext, sendData);
} catch (error) {
return null;
}
};
const getNodeParameterActionResult = async (
sendData: DynamicNodeParameters.ActionResultRequest,
) => {
@ -326,6 +336,7 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => {
visibleNodeTypesByInputConnectionTypeNames,
isConfigurableNode,
getResourceMapperFields,
getLocalResourceMapperFields,
getNodeParameterActionResult,
getResourceLocatorResults,
getNodeParameterOptions,

View file

@ -0,0 +1,75 @@
import type { ResourceMapperField } from 'n8n-workflow';
import { isResourceMapperFieldListStale } from './nodeTypesUtils';
describe('isResourceMapperFieldListStale', () => {
const baseField: ResourceMapperField = {
id: 'test',
displayName: 'test',
required: false,
defaultMatch: false,
display: true,
canBeUsedToMatch: true,
type: 'string',
};
// Test property changes
test.each([
[
'displayName',
{ ...baseField },
{ ...baseField, displayName: 'changed' } as ResourceMapperField,
],
['required', { ...baseField }, { ...baseField, required: true } as ResourceMapperField],
['defaultMatch', { ...baseField }, { ...baseField, defaultMatch: true } as ResourceMapperField],
['display', { ...baseField }, { ...baseField, display: false }],
[
'canBeUsedToMatch',
{ ...baseField },
{ ...baseField, canBeUsedToMatch: false } as ResourceMapperField,
],
['type', { ...baseField }, { ...baseField, type: 'number' } as ResourceMapperField],
])('returns true when %s changes', (_property, oldField, newField) => {
expect(isResourceMapperFieldListStale([oldField], [newField])).toBe(true);
});
// Test different array lengths
test.each([
['empty vs non-empty', [], [baseField]],
['non-empty vs empty', [baseField], []],
['one vs two fields', [baseField], [baseField, { ...baseField, id: 'test2' }]],
])('returns true for different lengths: %s', (_scenario, oldFields, newFields) => {
expect(isResourceMapperFieldListStale(oldFields, newFields)).toBe(true);
});
// Test identical cases
test.each([
['empty arrays', [], []],
['single field', [baseField], [{ ...baseField }]],
[
'multiple fields',
[
{ ...baseField, id: 'test1' },
{ ...baseField, id: 'test2' },
],
[
{ ...baseField, id: 'test1' },
{ ...baseField, id: 'test2' },
],
],
])('returns false for identical lists: %s', (_scenario, oldFields, newFields) => {
expect(isResourceMapperFieldListStale(oldFields, newFields)).toBe(false);
});
// This test case is complex enough to keep separate
test('returns true when field is removed/replaced', () => {
const oldFields = [
{ ...baseField, id: 'test1' },
{ ...baseField, id: 'test2' },
];
const newFields = [
{ ...baseField, id: 'test1' },
{ ...baseField, id: 'test3' }, // different id
];
expect(isResourceMapperFieldListStale(oldFields, newFields)).toBe(true);
});
});

View file

@ -440,6 +440,42 @@ export const fieldCannotBeDeleted = (
);
};
export const isResourceMapperFieldListStale = (
oldFields: ResourceMapperField[],
newFields: ResourceMapperField[],
): boolean => {
if (oldFields.length !== newFields.length) {
return true;
}
// Create map for O(1) lookup
const newFieldsMap = new Map(newFields.map((field) => [field.id, field]));
// Check if any fields were removed or modified
for (const oldField of oldFields) {
const newField = newFieldsMap.get(oldField.id);
// Field was removed
if (!newField) {
return true;
}
// Check if any properties changed
if (
oldField.displayName !== newField.displayName ||
oldField.required !== newField.required ||
oldField.defaultMatch !== newField.defaultMatch ||
oldField.display !== newField.display ||
oldField.canBeUsedToMatch !== newField.canBeUsedToMatch ||
oldField.type !== newField.type
) {
return true;
}
}
return false;
};
export const isMatchingField = (
field: string,
matchingFields: string[],

View file

@ -0,0 +1,113 @@
import { mock } from 'jest-mock-extended';
import type { IExecuteFunctions, IWorkflowDataProxyData } from 'n8n-workflow';
import { ExecuteWorkflow } from './ExecuteWorkflow.node';
import { getWorkflowInfo } from './GenericFunctions';
jest.mock('./GenericFunctions');
jest.mock('../../../utils/utilities');
describe('ExecuteWorkflow', () => {
const executeWorkflow = new ExecuteWorkflow();
const executeFunctions = mock<IExecuteFunctions>({
getNodeParameter: jest.fn(),
getInputData: jest.fn(),
getWorkflowDataProxy: jest.fn(),
executeWorkflow: jest.fn(),
continueOnFail: jest.fn(),
setMetadata: jest.fn(),
getNode: jest.fn(),
});
beforeEach(() => {
jest.clearAllMocks();
executeFunctions.getInputData.mockReturnValue([{ json: { key: 'value' } }]);
executeFunctions.getWorkflowDataProxy.mockReturnValue({
$workflow: { id: 'workflowId' },
$execution: { id: 'executionId' },
} as unknown as IWorkflowDataProxyData);
});
test('should execute workflow in "each" mode and wait for sub-workflow completion', async () => {
executeFunctions.getNodeParameter
.mockReturnValueOnce('database') // source
.mockReturnValueOnce('each') // mode
.mockReturnValueOnce(true) // waitForSubWorkflow
.mockReturnValueOnce([]); // workflowInputs.schema
executeFunctions.getInputData.mockReturnValue([{ json: { key: 'value' } }]);
executeFunctions.getWorkflowDataProxy.mockReturnValue({
$workflow: { id: 'workflowId' },
$execution: { id: 'executionId' },
} as unknown as IWorkflowDataProxyData);
(getWorkflowInfo as jest.Mock).mockResolvedValue({ id: 'subWorkflowId' });
(executeFunctions.executeWorkflow as jest.Mock).mockResolvedValue({
executionId: 'subExecutionId',
data: [[{ json: { key: 'subValue' } }]],
});
const result = await executeWorkflow.execute.call(executeFunctions);
expect(result).toEqual([
[
{
json: { key: 'value' },
index: 0,
pairedItem: { item: 0 },
metadata: {
subExecution: { workflowId: 'subWorkflowId', executionId: 'subExecutionId' },
},
},
],
]);
});
test('should execute workflow in "once" mode and not wait for sub-workflow completion', async () => {
executeFunctions.getNodeParameter
.mockReturnValueOnce('database') // source
.mockReturnValueOnce('once') // mode
.mockReturnValueOnce(false) // waitForSubWorkflow
.mockReturnValueOnce([]); // workflowInputs.schema
executeFunctions.getInputData.mockReturnValue([{ json: { key: 'value' } }]);
executeFunctions.executeWorkflow.mockResolvedValue({
executionId: 'subExecutionId',
data: [[{ json: { key: 'subValue' } }]],
});
const result = await executeWorkflow.execute.call(executeFunctions);
expect(result).toEqual([[{ json: { key: 'value' }, index: 0, pairedItem: { item: 0 } }]]);
});
test('should handle errors and continue on fail', async () => {
executeFunctions.getNodeParameter
.mockReturnValueOnce('database') // source
.mockReturnValueOnce('each') // mode
.mockReturnValueOnce(true) // waitForSubWorkflow
.mockReturnValueOnce([]); // workflowInputs.schema
(getWorkflowInfo as jest.Mock).mockRejectedValue(new Error('Test error'));
(executeFunctions.continueOnFail as jest.Mock).mockReturnValue(true);
const result = await executeWorkflow.execute.call(executeFunctions);
expect(result).toEqual([[{ json: { error: 'Test error' }, pairedItem: { item: 0 } }]]);
});
test('should throw error if not continuing on fail', async () => {
executeFunctions.getNodeParameter
.mockReturnValueOnce('database') // source
.mockReturnValueOnce('each') // mode
.mockReturnValueOnce(true) // waitForSubWorkflow
.mockReturnValueOnce([]); // workflowInputs.schema
(getWorkflowInfo as jest.Mock).mockRejectedValue(new Error('Test error'));
(executeFunctions.continueOnFail as jest.Mock).mockReturnValue(false);
await expect(executeWorkflow.execute.call(executeFunctions)).rejects.toThrow(
'Error executing workflow with item at index 0',
);
});
});

View file

@ -8,8 +8,11 @@ import type {
} from 'n8n-workflow';
import { getWorkflowInfo } from './GenericFunctions';
import { generatePairedItemData } from '../../utils/utilities';
import { generatePairedItemData } from '../../../utils/utilities';
import {
getCurrentWorkflowInputData,
loadWorkflowInputMappings,
} from '../../../utils/workflowInputsResourceMapping/GenericFunctions';
export class ExecuteWorkflow implements INodeType {
description: INodeTypeDescription = {
displayName: 'Execute Workflow',
@ -17,7 +20,7 @@ export class ExecuteWorkflow implements INodeType {
icon: 'fa:sign-in-alt',
iconColor: 'orange-red',
group: ['transform'],
version: [1, 1.1],
version: [1, 1.1, 1.2],
subtitle: '={{"Workflow: " + $parameter["workflowId"]}}',
description: 'Execute another workflow',
defaults: {
@ -40,6 +43,13 @@ export class ExecuteWorkflow implements INodeType {
},
],
},
{
displayName: 'This node is out of date. Please upgrade by removing it and adding a new one',
name: 'outdatedVersionWarning',
type: 'notice',
displayOptions: { show: { '@version': [{ _cnd: { lte: 1.1 } }] } },
default: '',
},
{
displayName: 'Source',
name: 'source',
@ -68,6 +78,27 @@ export class ExecuteWorkflow implements INodeType {
],
default: 'database',
description: 'Where to get the workflow to execute from',
displayOptions: { show: { '@version': [{ _cnd: { lte: 1.1 } }] } },
},
{
displayName: 'Source',
name: 'source',
type: 'options',
options: [
{
name: 'Database',
value: 'database',
description: 'Load the workflow from the database by ID',
},
{
name: 'Define Below',
value: 'parameter',
description: 'Pass the JSON code of a workflow',
},
],
default: 'database',
description: 'Where to get the workflow to execute from',
displayOptions: { show: { '@version': [{ _cnd: { gte: 1.2 } }] } },
},
// ----------------------------------
@ -164,6 +195,43 @@ export class ExecuteWorkflow implements INodeType {
name: 'executeWorkflowNotice',
type: 'notice',
default: '',
displayOptions: { show: { '@version': [{ _cnd: { lte: 1.1 } }] } },
},
{
displayName: 'Workflow Inputs',
name: 'workflowInputs',
type: 'resourceMapper',
noDataExpression: true,
default: {
mappingMode: 'defineBelow',
value: null,
},
required: true,
typeOptions: {
loadOptionsDependsOn: ['workflowId.value'],
resourceMapper: {
localResourceMapperMethod: 'loadWorkflowInputMappings',
valuesLabel: 'Workflow Inputs',
mode: 'map',
fieldWords: {
singular: 'input',
plural: 'inputs',
},
addAllFields: true,
multiKeyMatch: false,
supportAutoMap: false,
showTypeConversionOptions: true,
},
},
displayOptions: {
show: {
source: ['database'],
'@version': [{ _cnd: { gte: 1.2 } }],
},
hide: {
workflowId: [''],
},
},
},
{
displayName: 'Mode',
@ -206,10 +274,16 @@ export class ExecuteWorkflow implements INodeType {
],
};
methods = {
localResourceMapping: {
loadWorkflowInputMappings,
},
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const source = this.getNodeParameter('source', 0) as string;
const mode = this.getNodeParameter('mode', 0, false) as string;
const items = this.getInputData();
const items = getCurrentWorkflowInputData.call(this);
const workflowProxy = this.getWorkflowDataProxy(0);
const currentWorkflowId = workflowProxy.$workflow.id as string;

View file

@ -3,11 +3,16 @@ import { NodeOperationError, jsonParse } from 'n8n-workflow';
import type {
IExecuteFunctions,
IExecuteWorkflowInfo,
ILoadOptionsFunctions,
INodeParameterResourceLocator,
IRequestOptions,
} from 'n8n-workflow';
export async function getWorkflowInfo(this: IExecuteFunctions, source: string, itemIndex = 0) {
export async function getWorkflowInfo(
this: ILoadOptionsFunctions | IExecuteFunctions,
source: string,
itemIndex = 0,
) {
const workflowInfo: IExecuteWorkflowInfo = {};
const nodeVersion = this.getNode().typeVersion;
if (source === 'database') {

View file

@ -0,0 +1,53 @@
import { mock } from 'jest-mock-extended';
import type { FieldValueOption, IExecuteFunctions, INode, INodeExecutionData } from 'n8n-workflow';
import { ExecuteWorkflowTrigger } from './ExecuteWorkflowTrigger.node';
import { WORKFLOW_INPUTS } from '../../../utils/workflowInputsResourceMapping/constants';
import { getFieldEntries } from '../../../utils/workflowInputsResourceMapping/GenericFunctions';
jest.mock('../../../utils/workflowInputsResourceMapping/GenericFunctions', () => ({
getFieldEntries: jest.fn(),
getWorkflowInputData: jest.fn(),
}));
describe('ExecuteWorkflowTrigger', () => {
const mockInputData: INodeExecutionData[] = [
{ json: { item: 0, foo: 'bar' }, index: 0 },
{ json: { item: 1, foo: 'quz' }, index: 1 },
];
const mockNode = mock<INode>({ typeVersion: 1 });
const executeFns = mock<IExecuteFunctions>({
getInputData: () => mockInputData,
getNode: () => mockNode,
getNodeParameter: jest.fn(),
});
it('should return its input data on V1 or V1.1 passthrough', async () => {
// User selection in V1.1, or fallback return value in V1 with dropdown not displayed
executeFns.getNodeParameter.mockReturnValueOnce('passthrough');
const result = await new ExecuteWorkflowTrigger().execute.call(executeFns);
expect(result).toEqual([mockInputData]);
});
it('should filter out parent input in `Using Fields below` mode', async () => {
executeFns.getNodeParameter.mockReturnValueOnce(WORKFLOW_INPUTS);
const mockNewParams = [
{ name: 'value1', type: 'string' },
{ name: 'value2', type: 'number' },
{ name: 'foo', type: 'string' },
] as FieldValueOption[];
const getFieldEntriesMock = (getFieldEntries as jest.Mock).mockReturnValue(mockNewParams);
const result = await new ExecuteWorkflowTrigger().execute.call(executeFns);
const expected = [
[
{ index: 0, json: { value1: null, value2: null, foo: mockInputData[0].json.foo } },
{ index: 1, json: { value1: null, value2: null, foo: mockInputData[1].json.foo } },
],
];
expect(result).toEqual(expected);
expect(getFieldEntriesMock).toHaveBeenCalledWith(executeFns);
});
});

View file

@ -0,0 +1,225 @@
import _ from 'lodash';
import {
type INodeExecutionData,
NodeConnectionType,
type IExecuteFunctions,
type INodeType,
type INodeTypeDescription,
} from 'n8n-workflow';
import {
INPUT_SOURCE,
WORKFLOW_INPUTS,
JSON_EXAMPLE,
VALUES,
TYPE_OPTIONS,
PASSTHROUGH,
FALLBACK_DEFAULT_VALUE,
} from '../../../utils/workflowInputsResourceMapping/constants';
import { getFieldEntries } from '../../../utils/workflowInputsResourceMapping/GenericFunctions';
export class ExecuteWorkflowTrigger implements INodeType {
description: INodeTypeDescription = {
displayName: 'Execute Workflow Trigger',
name: 'executeWorkflowTrigger',
icon: 'fa:sign-out-alt',
group: ['trigger'],
version: [1, 1.1],
description:
'Helpers for calling other n8n workflows. Used for designing modular, microservice-like workflows.',
eventTriggerDescription: '',
maxNodes: 1,
defaults: {
name: 'Workflow Input Trigger',
color: '#ff6d5a',
},
inputs: [],
outputs: [NodeConnectionType.Main],
hints: [
{
message: 'Please make sure to define your input fields.',
// This condition checks if we have no input fields, which gets a bit awkward:
// For WORKFLOW_INPUTS: keys() only contains `VALUES` if at least one value is provided
// For JSON_EXAMPLE: We remove all whitespace and check if we're left with an empty object. Note that we already error if the example is not valid JSON
displayCondition:
`={{$parameter['${INPUT_SOURCE}'] === '${WORKFLOW_INPUTS}' && !$parameter['${WORKFLOW_INPUTS}'].keys().length ` +
`|| $parameter['${INPUT_SOURCE}'] === '${JSON_EXAMPLE}' && $parameter['${JSON_EXAMPLE}'].toString().replaceAll(' ', '').replaceAll('\\n', '') === '{}' }}`,
whenToDisplay: 'always',
location: 'ndv',
},
],
properties: [
{
displayName: 'Events',
name: 'events',
type: 'hidden',
noDataExpression: true,
options: [
{
name: 'Workflow Call',
value: 'worklfow_call',
description: 'When called by another workflow using Execute Workflow Trigger',
action: 'When Called by Another Workflow',
},
],
default: 'worklfow_call',
},
{
displayName:
"When an execute workflow node calls this workflow, the execution starts here. Any data passed into the 'execute workflow' node will be output by this node.",
name: 'notice',
type: 'notice',
default: '',
displayOptions: {
show: { '@version': [{ _cnd: { eq: 1 } }] },
},
},
{
displayName: 'This node is out of date. Please upgrade by removing it and adding a new one',
name: 'outdatedVersionWarning',
type: 'notice',
displayOptions: { show: { '@version': [{ _cnd: { eq: 1 } }] } },
default: '',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
displayName: 'Input data mode',
name: INPUT_SOURCE,
type: 'options',
options: [
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
name: 'Define using fields below',
value: WORKFLOW_INPUTS,
description: 'Provide input fields via UI',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
name: 'Define using JSON example',
value: JSON_EXAMPLE,
description: 'Generate a schema from an example JSON object',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
name: 'Accept all data',
value: PASSTHROUGH,
description: 'Use all incoming data from the parent workflow',
},
],
default: WORKFLOW_INPUTS,
noDataExpression: true,
displayOptions: {
show: { '@version': [{ _cnd: { gte: 1.1 } }] },
},
},
{
displayName:
'Provide an example object to infer fields and their types.<br>To allow any type for a given field, set the value to null.',
name: `${JSON_EXAMPLE}_notice`,
type: 'notice',
default: '',
displayOptions: {
show: { '@version': [{ _cnd: { gte: 1.1 } }], inputSource: [JSON_EXAMPLE] },
},
},
{
displayName: 'JSON Example',
name: JSON_EXAMPLE,
type: 'json',
default: JSON.stringify(
{
aField: 'a string',
aNumber: 123,
thisFieldAcceptsAnyType: null,
anArray: [],
},
null,
2,
),
noDataExpression: true,
displayOptions: {
show: { '@version': [{ _cnd: { gte: 1.1 } }], inputSource: [JSON_EXAMPLE] },
},
},
{
displayName: 'Workflow Inputs',
name: WORKFLOW_INPUTS,
placeholder: 'Add field',
type: 'fixedCollection',
description:
'Define expected input fields. If no inputs are provided, all data from the calling workflow will be passed through.',
typeOptions: {
multipleValues: true,
sortable: true,
minRequiredFields: 1,
},
displayOptions: {
show: { '@version': [{ _cnd: { gte: 1.1 } }], inputSource: [WORKFLOW_INPUTS] },
},
default: {},
options: [
{
name: VALUES,
displayName: 'Values',
values: [
{
displayName: 'Name',
name: 'name',
type: 'string',
default: '',
placeholder: 'e.g. fieldName',
description: 'Name of the field',
required: true,
noDataExpression: true,
},
{
displayName: 'Type',
name: 'type',
type: 'options',
description: 'The field value type',
options: TYPE_OPTIONS,
required: true,
default: 'string',
noDataExpression: true,
},
],
},
],
},
],
};
async execute(this: IExecuteFunctions) {
const inputData = this.getInputData();
const inputSource = this.getNodeParameter(INPUT_SOURCE, 0, PASSTHROUGH) as string;
// Note on the data we receive from ExecuteWorkflow caller:
//
// The ExecuteWorkflow node typechecks all fields explicitly provided by the user here via the resourceMapper
// and removes all fields that are in the schema, but `removed` in the resourceMapper.
//
// In passthrough and legacy node versions, inputData will line up since the resourceMapper is empty,
// in which case all input is passed through.
// In other cases we will already have matching types and fields provided by the resource mapper,
// so we just need to be permissive on this end,
// while ensuring we provide default values for fields in our schema, which are removed in the resourceMapper.
if (inputSource === PASSTHROUGH) {
return [inputData];
} else {
const newParams = getFieldEntries(this);
const newKeys = new Set(newParams.map((x) => x.name));
const itemsInSchema: INodeExecutionData[] = inputData.map((row, index) => ({
json: {
...Object.fromEntries(newParams.map((x) => [x.name, FALLBACK_DEFAULT_VALUE])),
// Need to trim to the expected schema to support legacy Execute Workflow callers passing through all their data
// which we do not want to expose past this node.
..._.pickBy(row.json, (_value, key) => newKeys.has(key)),
},
index,
}));
return [itemsInSchema];
}
}
}

View file

@ -1,55 +0,0 @@
import {
NodeConnectionType,
type IExecuteFunctions,
type INodeType,
type INodeTypeDescription,
} from 'n8n-workflow';
export class ExecuteWorkflowTrigger implements INodeType {
description: INodeTypeDescription = {
displayName: 'Execute Workflow Trigger',
name: 'executeWorkflowTrigger',
icon: 'fa:sign-out-alt',
group: ['trigger'],
version: 1,
description:
'Helpers for calling other n8n workflows. Used for designing modular, microservice-like workflows.',
eventTriggerDescription: '',
maxNodes: 1,
defaults: {
name: 'Execute Workflow Trigger',
color: '#ff6d5a',
},
inputs: [],
outputs: [NodeConnectionType.Main],
properties: [
{
displayName:
"When an execute workflow node calls this workflow, the execution starts here. Any data passed into the 'execute workflow' node will be output by this node.",
name: 'notice',
type: 'notice',
default: '',
},
{
displayName: 'Events',
name: 'events',
type: 'hidden',
noDataExpression: true,
options: [
{
name: 'Workflow Call',
value: 'worklfow_call',
description: 'When called by another workflow using Execute Workflow Trigger',
action: 'When Called by Another Workflow',
},
],
default: 'worklfow_call',
},
],
};
async execute(this: IExecuteFunctions) {
return [this.getInputData()];
}
}

View file

@ -1,19 +0,0 @@
import { mock } from 'jest-mock-extended';
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { ExecuteWorkflowTrigger } from '../ExecuteWorkflowTrigger.node';
describe('ExecuteWorkflowTrigger', () => {
it('should return its input data', async () => {
const mockInputData: INodeExecutionData[] = [
{ json: { item: 0, foo: 'bar' } },
{ json: { item: 1, foo: 'quz' } },
];
const executeFns = mock<IExecuteFunctions>({
getInputData: () => mockInputData,
});
const result = await new ExecuteWorkflowTrigger().execute.call(executeFns);
expect(result).toEqual([mockInputData]);
});
});

View file

@ -493,8 +493,8 @@
"dist/nodes/ErrorTrigger/ErrorTrigger.node.js",
"dist/nodes/Eventbrite/EventbriteTrigger.node.js",
"dist/nodes/ExecuteCommand/ExecuteCommand.node.js",
"dist/nodes/ExecuteWorkflow/ExecuteWorkflow.node.js",
"dist/nodes/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.js",
"dist/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.js",
"dist/nodes/ExecuteWorkflow/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.js",
"dist/nodes/ExecutionData/ExecutionData.node.js",
"dist/nodes/Facebook/FacebookGraphApi.node.js",
"dist/nodes/Facebook/FacebookTrigger.node.js",
@ -867,6 +867,7 @@
"fast-glob": "catalog:",
"fflate": "0.7.4",
"get-system-fonts": "2.0.2",
"generate-schema": "2.6.0",
"gm": "1.25.0",
"html-to-text": "9.0.5",
"iconv-lite": "catalog:",

View file

@ -8,7 +8,8 @@
"credentials/**/*.ts",
"nodes/**/*.ts",
"nodes/**/*.json",
"credentials/translations/**/*.json"
"credentials/translations/**/*.json",
"types/**/*.ts"
],
"exclude": ["nodes/**/*.test.ts", "credentials/**/*.test.ts", "test/**"]
}

View file

@ -10,7 +10,13 @@
"noImplicitReturns": false,
"useUnknownInCatchVariables": false
},
"include": ["credentials/**/*.ts", "nodes/**/*.ts", "test/**/*.ts", "utils/**/*.ts"],
"include": [
"credentials/**/*.ts",
"nodes/**/*.ts",
"test/**/*.ts",
"utils/**/*.ts",
"types/**/*.ts"
],
"references": [
{ "path": "../@n8n/imap/tsconfig.build.json" },
{ "path": "../workflow/tsconfig.build.json" },

View file

@ -0,0 +1,27 @@
declare module 'generate-schema' {
export interface SchemaObject {
$schema: string;
title?: string;
type: string;
properties?: {
[key: string]: SchemaObject | SchemaArray | SchemaProperty;
};
required?: string[];
items?: SchemaObject | SchemaArray;
}
export interface SchemaArray {
type: string;
items?: SchemaObject | SchemaArray | SchemaProperty;
oneOf?: Array<SchemaObject | SchemaArray | SchemaProperty>;
required?: string[];
}
export interface SchemaProperty {
type: string | string[];
format?: string;
}
export function json(title: string, schema: SchemaObject): SchemaObject;
export function json(schema: SchemaObject): SchemaObject;
}

View file

@ -0,0 +1,5 @@
These files contain reusable logic for workflow inputs mapping used in these nodes:
- n8n-nodes-base.executeWorkflow
- n8n-nodes-base.executeWorkflowTrigger
- @n8n/n8n-nodes-langchain.toolWorkflow

View file

@ -0,0 +1,167 @@
import { json as generateSchemaFromExample, type SchemaObject } from 'generate-schema';
import type { JSONSchema7 } from 'json-schema';
import _ from 'lodash';
import type {
FieldValueOption,
FieldType,
IWorkflowNodeContext,
INodeExecutionData,
IDataObject,
ResourceMapperField,
ILocalLoadOptionsFunctions,
ResourceMapperFields,
ISupplyDataFunctions,
} from 'n8n-workflow';
import { jsonParse, NodeOperationError, EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE } from 'n8n-workflow';
import {
JSON_EXAMPLE,
INPUT_SOURCE,
WORKFLOW_INPUTS,
VALUES,
TYPE_OPTIONS,
PASSTHROUGH,
} from './constants';
const SUPPORTED_TYPES = TYPE_OPTIONS.map((x) => x.value);
function parseJsonSchema(schema: JSONSchema7): FieldValueOption[] | string {
if (!schema?.properties) {
return 'Invalid JSON schema. Missing key `properties` in schema';
}
if (typeof schema.properties !== 'object') {
return 'Invalid JSON schema. Key `properties` is not an object';
}
const result: FieldValueOption[] = [];
for (const [name, v] of Object.entries(schema.properties)) {
if (typeof v !== 'object') {
return `Invalid JSON schema. Value for property '${name}' is not an object`;
}
const type = v?.type;
if (type === 'null') {
result.push({ name, type: 'any' });
} else if (Array.isArray(type)) {
// Schema allows an array of types, but we don't
return `Invalid JSON schema. Array of types for property '${name}' is not supported by n8n. Either provide a single type or use type 'any' to allow any type`;
} else if (typeof type !== 'string') {
return `Invalid JSON schema. Unexpected non-string type ${type} for property '${name}'`;
} else if (!SUPPORTED_TYPES.includes(type as never)) {
return `Invalid JSON schema. Unsupported type ${type} for property '${name}'. Supported types are ${JSON.stringify(SUPPORTED_TYPES, null, 1)}`;
} else {
result.push({ name, type: type as FieldType });
}
}
return result;
}
function parseJsonExample(context: IWorkflowNodeContext): JSONSchema7 {
const jsonString = context.getNodeParameter(JSON_EXAMPLE, 0, '') as string;
const json = jsonParse<SchemaObject>(jsonString);
return generateSchemaFromExample(json) as JSONSchema7;
}
export function getFieldEntries(context: IWorkflowNodeContext): FieldValueOption[] {
const inputSource = context.getNodeParameter(INPUT_SOURCE, 0);
let result: FieldValueOption[] | string = 'Internal Error: Invalid input source';
try {
if (inputSource === WORKFLOW_INPUTS) {
result = context.getNodeParameter(
`${WORKFLOW_INPUTS}.${VALUES}`,
0,
[],
) as FieldValueOption[];
} else if (inputSource === JSON_EXAMPLE) {
const schema = parseJsonExample(context);
result = parseJsonSchema(schema);
} else if (inputSource === PASSTHROUGH) {
result = [];
}
} catch (e: unknown) {
result =
e && typeof e === 'object' && 'message' in e && typeof e.message === 'string'
? e.message
: `Unknown error occurred: ${JSON.stringify(e)}`;
}
if (Array.isArray(result)) {
return result;
}
throw new NodeOperationError(context.getNode(), result);
}
export function getWorkflowInputValues(this: ISupplyDataFunctions): INodeExecutionData[] {
const inputData = this.getInputData();
return inputData.map((item, itemIndex) => {
const itemFieldValues = this.getNodeParameter(
'workflowInputs.value',
itemIndex,
{},
) as IDataObject;
return {
json: {
...item.json,
...itemFieldValues,
},
index: itemIndex,
pairedItem: {
item: itemIndex,
},
};
});
}
export function getCurrentWorkflowInputData(this: ISupplyDataFunctions) {
const inputData: INodeExecutionData[] = getWorkflowInputValues.call(this);
const schema = this.getNodeParameter('workflowInputs.schema', 0, []) as ResourceMapperField[];
if (schema.length === 0) {
return inputData;
} else {
const removedKeys = new Set(schema.filter((x) => x.removed).map((x) => x.displayName));
const filteredInputData: INodeExecutionData[] = inputData.map((item, index) => ({
index,
pairedItem: { item: index },
json: _.pickBy(item.json, (_v, key) => !removedKeys.has(key)),
}));
return filteredInputData;
}
}
export async function loadWorkflowInputMappings(
this: ILocalLoadOptionsFunctions,
): Promise<ResourceMapperFields> {
const nodeLoadContext = await this.getWorkflowNodeContext(EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE);
let fields: ResourceMapperField[] = [];
if (nodeLoadContext) {
const fieldValues = getFieldEntries(nodeLoadContext);
fields = fieldValues.map((currentWorkflowInput) => {
const field: ResourceMapperField = {
id: currentWorkflowInput.name,
displayName: currentWorkflowInput.name,
required: false,
defaultMatch: false,
display: true,
canBeUsedToMatch: true,
};
if (currentWorkflowInput.type !== 'any') {
field.type = currentWorkflowInput.type;
}
return field;
});
}
return { fields };
}

View file

@ -0,0 +1,36 @@
import type { FieldType } from 'n8n-workflow';
export const INPUT_SOURCE = 'inputSource';
export const WORKFLOW_INPUTS = 'workflowInputs';
export const VALUES = 'values';
export const JSON_EXAMPLE = 'jsonExample';
export const PASSTHROUGH = 'passthrough';
export const TYPE_OPTIONS: Array<{ name: string; value: FieldType | 'any' }> = [
{
name: 'Allow Any Type',
value: 'any',
},
{
name: 'String',
value: 'string',
},
{
name: 'Number',
value: 'number',
},
{
name: 'Boolean',
value: 'boolean',
},
{
name: 'Array',
value: 'array',
},
{
name: 'Object',
value: 'object',
},
// Intentional omission of `dateTime`, `time`, `string-alphanumeric`, `form-fields`, `jwt` and `url`
];
export const FALLBACK_DEFAULT_VALUE = null;

View file

@ -1017,9 +1017,23 @@ export interface ILoadOptionsFunctions extends FunctionsBase {
options?: IGetNodeParameterOptions,
): NodeParameterValueType | object | undefined;
getCurrentNodeParameters(): INodeParameters | undefined;
helpers: RequestHelperFunctions & SSHTunnelFunctions;
}
export type FieldValueOption = { name: string; type: FieldType | 'any' };
export type IWorkflowNodeContext = ExecuteFunctions.GetNodeParameterFn &
Pick<FunctionsBase, 'getNode'>;
export interface ILocalLoadOptionsFunctions {
getWorkflowNodeContext(nodeType: string): Promise<IWorkflowNodeContext | null>;
}
export interface IWorkflowLoader {
get(workflowId: string): Promise<IWorkflowBase>;
}
export interface IPollFunctions
extends FunctionsBaseWithRequiredKeys<'getMode' | 'getActivationMode'> {
__emit(
@ -1293,14 +1307,18 @@ export interface INodePropertyTypeOptions {
resourceMapper?: ResourceMapperTypeOptions;
filter?: FilterTypeOptions;
assignment?: AssignmentTypeOptions;
minRequiredFields?: number; // Supported by: fixedCollection
maxAllowedFields?: number; // Supported by: fixedCollection
[key: string]: any;
}
export interface ResourceMapperTypeOptions {
resourceMapperMethod: string;
mode: 'add' | 'update' | 'upsert';
export interface ResourceMapperTypeOptionsBase {
mode: 'add' | 'update' | 'upsert' | 'map';
valuesLabel?: string;
fieldWords?: { singular: string; plural: string };
fieldWords?: {
singular: string;
plural: string;
};
addAllFields?: boolean;
noFieldsError?: string;
multiKeyMatch?: boolean;
@ -1310,8 +1328,23 @@ export interface ResourceMapperTypeOptions {
description?: string;
hint?: string;
};
showTypeConversionOptions?: boolean;
}
// Enforce at least one of resourceMapperMethod or localResourceMapperMethod
export type ResourceMapperTypeOptionsLocal = {
resourceMapperMethod: string;
localResourceMapperMethod?: never; // Explicitly disallows this property
};
export type ResourceMapperTypeOptionsExternal = {
localResourceMapperMethod: string;
resourceMapperMethod?: never; // Explicitly disallows this property
};
export type ResourceMapperTypeOptions = ResourceMapperTypeOptionsBase &
(ResourceMapperTypeOptionsLocal | ResourceMapperTypeOptionsExternal);
type NonEmptyArray<T> = [T, ...T[]];
export type FilterTypeCombinator = 'and' | 'or';
@ -1583,6 +1616,9 @@ export interface INodeType {
resourceMapping?: {
[functionName: string]: (this: ILoadOptionsFunctions) => Promise<ResourceMapperFields>;
};
localResourceMapping?: {
[functionName: string]: (this: ILocalLoadOptionsFunctions) => Promise<ResourceMapperFields>;
};
actionHandler?: {
[functionName: string]: (
this: ILoadOptionsFunctions,
@ -2651,6 +2687,9 @@ export type ResourceMapperValue = {
value: { [key: string]: string | number | boolean | null } | null;
matchingColumns: string[];
schema: ResourceMapperField[];
ignoreTypeMismatchErrors: boolean;
attemptToConvertTypes: boolean;
convertFieldsToString: boolean;
};
export type FilterOperatorType =

View file

@ -1568,7 +1568,7 @@ export function getParameterIssues(
data: option as INodeProperties,
});
}
} else if (nodeProperties.type === 'fixedCollection') {
} else if (nodeProperties.type === 'fixedCollection' && isDisplayed) {
basePath = basePath ? `${basePath}.` : `${nodeProperties.name}.`;
let propertyOptions: INodePropertyCollection;
@ -1579,6 +1579,24 @@ export function getParameterIssues(
propertyOptions.name,
basePath.slice(0, -1),
);
// Validate allowed field counts
const valueArray = Array.isArray(value) ? value : [];
const { minRequiredFields, maxAllowedFields } = nodeProperties.typeOptions ?? {};
let error = '';
if (minRequiredFields && valueArray.length < minRequiredFields) {
error = `At least ${minRequiredFields} ${minRequiredFields === 1 ? 'field is' : 'fields are'} required.`;
}
if (maxAllowedFields && valueArray.length > maxAllowedFields) {
error = `At most ${maxAllowedFields} ${maxAllowedFields === 1 ? 'field is' : 'fields are'} allowed.`;
}
if (error) {
foundIssues.parameters ??= {};
foundIssues.parameters[nodeProperties.name] ??= [];
foundIssues.parameters[nodeProperties.name].push(error);
}
if (value === undefined) {
continue;
}

View file

@ -974,7 +974,12 @@ export class WorkflowDataProxy {
type: 'no_execution_data',
});
}
return placeholdersDataInputData?.[name] ?? defaultValue;
return (
// TS does not know that the key exists, we need to address this in refactor
(placeholdersDataInputData?.query as Record<string, unknown>)?.[name] ??
placeholdersDataInputData?.[name] ??
defaultValue
);
};
const base = {

View file

@ -1,5 +1,6 @@
import {
NodeConnectionType,
type INodeIssues,
type INode,
type INodeParameters,
type INodeProperties,
@ -11,6 +12,7 @@ import {
getNodeHints,
isSubNodeType,
applyDeclarativeNodeOptionParameters,
getParameterIssues,
} from '@/NodeHelpers';
import type { Workflow } from '@/Workflow';
@ -3607,4 +3609,590 @@ describe('NodeHelpers', () => {
expect(nodeType.description.properties).toEqual([]);
});
});
describe('getParameterIssues', () => {
const tests: Array<{
description: string;
input: {
nodeProperties: INodeProperties;
nodeValues: INodeParameters;
path: string;
node: INode;
};
output: INodeIssues;
}> = [
{
description:
'Fixed collection::Should not return issues if minimum or maximum field count is not set',
input: {
nodeProperties: {
displayName: 'Workflow Inputs',
name: 'workflowInputs',
placeholder: 'Add Field',
type: 'fixedCollection',
description:
'Define expected input fields. If no inputs are provided, all data from the calling workflow will be passed through.',
typeOptions: {
multipleValues: true,
sortable: true,
},
displayOptions: {
show: {
'@version': [
{
_cnd: {
gte: 1.1,
},
},
],
inputSource: ['workflowInputs'],
},
},
default: {},
options: [
{
name: 'values',
displayName: 'Values',
values: [
{
displayName: 'Name',
name: 'name',
type: 'string',
default: '',
placeholder: 'e.g. fieldName',
description: 'Name of the field',
noDataExpression: true,
},
{
displayName: 'Type',
name: 'type',
type: 'options',
description: 'The field value type',
options: [
{
name: 'Allow Any Type',
value: 'any',
},
{
name: 'String',
value: 'string',
},
{
name: 'Number',
value: 'number',
},
{
name: 'Boolean',
value: 'boolean',
},
{
name: 'Array',
value: 'array',
},
{
name: 'Object',
value: 'object',
},
],
default: 'string',
noDataExpression: true,
},
],
},
],
},
nodeValues: {
events: 'worklfow_call',
inputSource: 'workflowInputs',
workflowInputs: {},
inputOptions: {},
},
path: '',
node: {
parameters: {
events: 'worklfow_call',
inputSource: 'workflowInputs',
workflowInputs: {},
inputOptions: {},
},
type: 'n8n-nodes-base.executeWorkflowTrigger',
typeVersion: 1.1,
position: [-140, -20],
id: '9abdbdac-5f32-4876-b4d5-895d8ca4cb00',
name: 'Test Node',
} as INode,
},
output: {},
},
{
description:
'Fixed collection::Should not return issues if field count is within the specified range',
input: {
nodeProperties: {
displayName: 'Workflow Inputs',
name: 'workflowInputs',
placeholder: 'Add Field',
type: 'fixedCollection',
description:
'Define expected input fields. If no inputs are provided, all data from the calling workflow will be passed through.',
typeOptions: {
multipleValues: true,
sortable: true,
minRequiredFields: 1,
maxAllowedFields: 3,
},
displayOptions: {
show: {
'@version': [
{
_cnd: {
gte: 1.1,
},
},
],
inputSource: ['workflowInputs'],
},
},
default: {},
options: [
{
name: 'values',
displayName: 'Values',
values: [
{
displayName: 'Name',
name: 'name',
type: 'string',
default: '',
placeholder: 'e.g. fieldName',
description: 'Name of the field',
noDataExpression: true,
},
{
displayName: 'Type',
name: 'type',
type: 'options',
description: 'The field value type',
options: [
{
name: 'Allow Any Type',
value: 'any',
},
{
name: 'String',
value: 'string',
},
{
name: 'Number',
value: 'number',
},
{
name: 'Boolean',
value: 'boolean',
},
{
name: 'Array',
value: 'array',
},
{
name: 'Object',
value: 'object',
},
],
default: 'string',
noDataExpression: true,
},
],
},
],
},
nodeValues: {
events: 'worklfow_call',
inputSource: 'workflowInputs',
workflowInputs: {
values: [
{
name: 'field1',
type: 'string',
},
{
name: 'field2',
type: 'string',
},
],
},
inputOptions: {},
},
path: '',
node: {
parameters: {
events: 'worklfow_call',
inputSource: 'workflowInputs',
workflowInputs: {},
inputOptions: {},
},
type: 'n8n-nodes-base.executeWorkflowTrigger',
typeVersion: 1.1,
position: [-140, -20],
id: '9abdbdac-5f32-4876-b4d5-895d8ca4cb00',
name: 'Test Node',
} as INode,
},
output: {},
},
{
description:
'Fixed collection::Should return an issue if field count is lower than minimum specified',
input: {
nodeProperties: {
displayName: 'Workflow Inputs',
name: 'workflowInputs',
placeholder: 'Add Field',
type: 'fixedCollection',
description:
'Define expected input fields. If no inputs are provided, all data from the calling workflow will be passed through.',
typeOptions: {
multipleValues: true,
sortable: true,
minRequiredFields: 1,
},
displayOptions: {
show: {
'@version': [
{
_cnd: {
gte: 1.1,
},
},
],
inputSource: ['workflowInputs'],
},
},
default: {},
options: [
{
name: 'values',
displayName: 'Values',
values: [
{
displayName: 'Name',
name: 'name',
type: 'string',
default: '',
placeholder: 'e.g. fieldName',
description: 'Name of the field',
noDataExpression: true,
},
{
displayName: 'Type',
name: 'type',
type: 'options',
description: 'The field value type',
options: [
{
name: 'Allow Any Type',
value: 'any',
},
{
name: 'String',
value: 'string',
},
{
name: 'Number',
value: 'number',
},
{
name: 'Boolean',
value: 'boolean',
},
{
name: 'Array',
value: 'array',
},
{
name: 'Object',
value: 'object',
},
],
default: 'string',
noDataExpression: true,
},
],
},
],
},
nodeValues: {
events: 'worklfow_call',
inputSource: 'workflowInputs',
workflowInputs: {},
inputOptions: {},
},
path: '',
node: {
parameters: {
events: 'worklfow_call',
inputSource: 'workflowInputs',
workflowInputs: {},
inputOptions: {},
},
type: 'n8n-nodes-base.executeWorkflowTrigger',
typeVersion: 1.1,
position: [-140, -20],
id: '9abdbdac-5f32-4876-b4d5-895d8ca4cb00',
name: 'Test Node',
} as INode,
},
output: {
parameters: {
workflowInputs: ['At least 1 field is required.'],
},
},
},
{
description:
'Fixed collection::Should return an issue if field count is higher than maximum specified',
input: {
nodeProperties: {
displayName: 'Workflow Inputs',
name: 'workflowInputs',
placeholder: 'Add Field',
type: 'fixedCollection',
description:
'Define expected input fields. If no inputs are provided, all data from the calling workflow will be passed through.',
typeOptions: {
multipleValues: true,
sortable: true,
maxAllowedFields: 1,
},
displayOptions: {
show: {
'@version': [
{
_cnd: {
gte: 1.1,
},
},
],
inputSource: ['workflowInputs'],
},
},
default: {},
options: [
{
name: 'values',
displayName: 'Values',
values: [
{
displayName: 'Name',
name: 'name',
type: 'string',
default: '',
placeholder: 'e.g. fieldName',
description: 'Name of the field',
noDataExpression: true,
},
{
displayName: 'Type',
name: 'type',
type: 'options',
description: 'The field value type',
options: [
{
name: 'Allow Any Type',
value: 'any',
},
{
name: 'String',
value: 'string',
},
{
name: 'Number',
value: 'number',
},
{
name: 'Boolean',
value: 'boolean',
},
{
name: 'Array',
value: 'array',
},
{
name: 'Object',
value: 'object',
},
],
default: 'string',
noDataExpression: true,
},
],
},
],
},
nodeValues: {
events: 'worklfow_call',
inputSource: 'workflowInputs',
workflowInputs: {
values: [
{
name: 'field1',
type: 'string',
},
{
name: 'field2',
type: 'string',
},
],
},
inputOptions: {},
},
path: '',
node: {
parameters: {
events: 'worklfow_call',
inputSource: 'workflowInputs',
workflowInputs: {},
inputOptions: {},
},
type: 'n8n-nodes-base.executeWorkflowTrigger',
typeVersion: 1.1,
position: [-140, -20],
id: '9abdbdac-5f32-4876-b4d5-895d8ca4cb00',
name: 'Test Node',
} as INode,
},
output: {
parameters: {
workflowInputs: ['At most 1 field is allowed.'],
},
},
},
{
description: 'Fixed collection::Should not return issues if the collection is hidden',
input: {
nodeProperties: {
displayName: 'Workflow Inputs',
name: 'workflowInputs',
placeholder: 'Add Field',
type: 'fixedCollection',
description:
'Define expected input fields. If no inputs are provided, all data from the calling workflow will be passed through.',
typeOptions: {
multipleValues: true,
sortable: true,
maxAllowedFields: 1,
},
displayOptions: {
show: {
'@version': [
{
_cnd: {
gte: 1.1,
},
},
],
inputSource: ['workflowInputs'],
},
},
default: {},
options: [
{
name: 'values',
displayName: 'Values',
values: [
{
displayName: 'Name',
name: 'name',
type: 'string',
default: '',
placeholder: 'e.g. fieldName',
description: 'Name of the field',
noDataExpression: true,
},
{
displayName: 'Type',
name: 'type',
type: 'options',
description: 'The field value type',
options: [
{
name: 'Allow Any Type',
value: 'any',
},
{
name: 'String',
value: 'string',
},
{
name: 'Number',
value: 'number',
},
{
name: 'Boolean',
value: 'boolean',
},
{
name: 'Array',
value: 'array',
},
{
name: 'Object',
value: 'object',
},
],
default: 'string',
noDataExpression: true,
},
],
},
],
},
nodeValues: {
events: 'worklfow_call',
inputSource: 'somethingElse',
workflowInputs: {
values: [
{
name: 'field1',
type: 'string',
},
{
name: 'field2',
type: 'string',
},
],
},
inputOptions: {},
},
path: '',
node: {
parameters: {
events: 'worklfow_call',
inputSource: 'workflowInputs',
workflowInputs: {},
inputOptions: {},
},
type: 'n8n-nodes-base.executeWorkflowTrigger',
typeVersion: 1.1,
position: [-140, -20],
id: '9abdbdac-5f32-4876-b4d5-895d8ca4cb00',
name: 'Test Node',
} as INode,
},
output: {},
},
];
for (const testData of tests) {
test(testData.description, () => {
const result = getParameterIssues(
testData.input.nodeProperties,
testData.input.nodeValues,
testData.input.path,
testData.input.node,
);
expect(result).toEqual(testData.output);
});
}
});
});

View file

@ -1698,6 +1698,9 @@ importers:
fflate:
specifier: 0.7.4
version: 0.7.4
generate-schema:
specifier: 2.6.0
version: 2.6.0
get-system-fonts:
specifier: 2.0.2
version: 2.0.2