mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 04:04:06 -08:00
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:
parent
6c323e4e49
commit
d4116630a6
288
cypress/e2e/48-subworkflow-inputs.cy.ts
Normal file
288
cypress/e2e/48-subworkflow-inputs.cy.ts
Normal 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');
|
||||
});
|
||||
});
|
70
cypress/fixtures/Test_Subworkflow-Inputs.json
Normal file
70
cypress/fixtures/Test_Subworkflow-Inputs.json
Normal 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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
|
@ -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 });
|
||||
},
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
],
|
||||
};
|
|
@ -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 };
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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',
|
||||
},
|
||||
],
|
||||
};
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
19
packages/cli/src/services/workflow-loader.service.ts
Normal file
19
packages/cli/src/services/workflow-loader.service.ts
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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
|
||||
*
|
||||
|
|
|
@ -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.',
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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'];
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
52
packages/editor-ui/src/composables/useDocumentVisibility.ts
Normal file
52
packages/editor-ui/src/composables/useDocumentVisibility.ts
Normal 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,
|
||||
};
|
||||
}
|
|
@ -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: [
|
||||
[
|
||||
{
|
||||
|
|
|
@ -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 can’t be removed",
|
||||
"resourceMapper.addFieldToSend": "Add {fieldWord} to Send",
|
||||
"resourceMapper.addFieldToSend": "Add {fieldWord} to send",
|
||||
"resourceMapper.matching.title": "This {fieldWord} is used for matching and can’t 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.",
|
||||
|
|
|
@ -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,
|
||||
|
|
75
packages/editor-ui/src/utils/nodeTypeUtils.test.ts
Normal file
75
packages/editor-ui/src/utils/nodeTypeUtils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
|
@ -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[],
|
||||
|
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -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') {
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()];
|
||||
}
|
||||
}
|
|
@ -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]);
|
||||
});
|
||||
});
|
|
@ -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:",
|
||||
|
|
|
@ -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/**"]
|
||||
}
|
||||
|
|
|
@ -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" },
|
||||
|
|
27
packages/nodes-base/types/generate-schema.d.ts
vendored
Normal file
27
packages/nodes-base/types/generate-schema.d.ts
vendored
Normal 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;
|
||||
}
|
|
@ -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
|
|
@ -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 };
|
||||
}
|
|
@ -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;
|
|
@ -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 =
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue