mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-12 13:27:31 -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) => {
|
addItemToFixedCollection: (paramName: string) => {
|
||||||
this.getters.fixedCollectionParameter(paramName).getByTestId('fixed-collection-add').click();
|
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: () => {
|
dragMainPanelToLeft: () => {
|
||||||
cy.drag('[data-test-id=panel-drag-button]', [-1000, 0], { moveTwice: true });
|
cy.drag('[data-test-id=panel-drag-button]', [-1000, 0], { moveTwice: true });
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,44 +1,18 @@
|
||||||
import type { CallbackManagerForToolRun } from '@langchain/core/callbacks/manager';
|
import type { IVersionedNodeType, INodeTypeBaseDescription } from 'n8n-workflow';
|
||||||
import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools';
|
import { VersionedNodeType } from 'n8n-workflow';
|
||||||
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 { jsonSchemaExampleField, schemaTypeField, inputSchemaField } from '@utils/descriptions';
|
import { ToolWorkflowV1 } from './v1/ToolWorkflowV1.node';
|
||||||
import { convertJsonSchemaToZod, generateSchema } from '@utils/schemaParsing';
|
import { ToolWorkflowV2 } from './v2/ToolWorkflowV2.node';
|
||||||
import { getConnectionHintNoticeField } from '@utils/sharedFields';
|
|
||||||
|
|
||||||
import type { DynamicZodObject } from '../../../types/zod.types';
|
export class ToolWorkflow extends VersionedNodeType {
|
||||||
|
constructor() {
|
||||||
export class ToolWorkflow implements INodeType {
|
const baseDescription: INodeTypeBaseDescription = {
|
||||||
description: INodeTypeDescription = {
|
displayName: 'Call n8n Sub-Workflow Tool',
|
||||||
displayName: 'Call n8n Workflow Tool',
|
|
||||||
name: 'toolWorkflow',
|
name: 'toolWorkflow',
|
||||||
icon: 'fa:network-wired',
|
icon: 'fa:network-wired',
|
||||||
iconColor: 'black',
|
|
||||||
group: ['transform'],
|
group: ['transform'],
|
||||||
version: [1, 1.1, 1.2, 1.3],
|
description:
|
||||||
description: 'Uses another n8n workflow as a tool. Allows packaging any n8n node(s) as a tool.',
|
'Uses another n8n workflow as a tool. Allows packaging any n8n node(s) as a tool.',
|
||||||
defaults: {
|
|
||||||
name: 'Call n8n Workflow Tool',
|
|
||||||
},
|
|
||||||
codex: {
|
codex: {
|
||||||
categories: ['AI'],
|
categories: ['AI'],
|
||||||
subcategories: {
|
subcategories: {
|
||||||
|
@ -53,515 +27,16 @@ export class ToolWorkflow implements INodeType {
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node
|
defaultVersion: 2,
|
||||||
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,
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
|
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
|
||||||
const workflowProxy = this.getWorkflowDataProxy(0);
|
1: new ToolWorkflowV1(baseDescription),
|
||||||
|
1.1: new ToolWorkflowV1(baseDescription),
|
||||||
const name = this.getNodeParameter('name', itemIndex) as string;
|
1.2: new ToolWorkflowV1(baseDescription),
|
||||||
const description = this.getNodeParameter('description', itemIndex) as string;
|
1.3: new ToolWorkflowV1(baseDescription),
|
||||||
|
2: new ToolWorkflowV2(baseDescription),
|
||||||
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,
|
|
||||||
};
|
};
|
||||||
|
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')
|
@Post('/action-result')
|
||||||
async getActionResult(
|
async getActionResult(
|
||||||
req: DynamicNodeParametersRequest.ActionResult,
|
req: DynamicNodeParametersRequest.ActionResult,
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { LoadOptionsContext, RoutingNode } from 'n8n-core';
|
import { LoadOptionsContext, RoutingNode, LocalLoadOptionsContext } from 'n8n-core';
|
||||||
import type {
|
import type {
|
||||||
ILoadOptions,
|
ILoadOptions,
|
||||||
ILoadOptionsFunctions,
|
ILoadOptionsFunctions,
|
||||||
|
@ -17,15 +17,43 @@ import type {
|
||||||
INodeTypeNameVersion,
|
INodeTypeNameVersion,
|
||||||
NodeParameterValueType,
|
NodeParameterValueType,
|
||||||
IDataObject,
|
IDataObject,
|
||||||
|
ILocalLoadOptionsFunctions,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { Workflow, ApplicationError } from 'n8n-workflow';
|
import { Workflow, ApplicationError } from 'n8n-workflow';
|
||||||
import { Service } from 'typedi';
|
import { Service } from 'typedi';
|
||||||
|
|
||||||
import { NodeTypes } from '@/node-types';
|
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()
|
@Service()
|
||||||
export class DynamicNodeParametersService {
|
export class DynamicNodeParametersService {
|
||||||
constructor(private nodeTypes: NodeTypes) {}
|
constructor(
|
||||||
|
private nodeTypes: NodeTypes,
|
||||||
|
private workflowLoaderService: WorkflowLoaderService,
|
||||||
|
) {}
|
||||||
|
|
||||||
/** Returns the available options via a predefined method */
|
/** Returns the available options via a predefined method */
|
||||||
async getOptionsViaMethodName(
|
async getOptionsViaMethodName(
|
||||||
|
@ -40,6 +68,8 @@ export class DynamicNodeParametersService {
|
||||||
const method = this.getMethod('loadOptions', methodName, nodeType);
|
const method = this.getMethod('loadOptions', methodName, nodeType);
|
||||||
const workflow = this.getWorkflow(nodeTypeAndVersion, currentNodeParameters, credentials);
|
const workflow = this.getWorkflow(nodeTypeAndVersion, currentNodeParameters, credentials);
|
||||||
const thisArgs = this.getThisArg(path, additionalData, workflow);
|
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
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||||
return method.call(thisArgs);
|
return method.call(thisArgs);
|
||||||
}
|
}
|
||||||
|
@ -157,6 +187,20 @@ export class DynamicNodeParametersService {
|
||||||
return method.call(thisArgs);
|
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 */
|
/** Returns the result of the action handler */
|
||||||
async getActionResult(
|
async getActionResult(
|
||||||
handler: string,
|
handler: string,
|
||||||
|
@ -179,33 +223,34 @@ export class DynamicNodeParametersService {
|
||||||
type: 'resourceMapping',
|
type: 'resourceMapping',
|
||||||
methodName: string,
|
methodName: string,
|
||||||
nodeType: INodeType,
|
nodeType: INodeType,
|
||||||
): (this: ILoadOptionsFunctions) => Promise<ResourceMapperFields>;
|
): ResourceMappingMethod;
|
||||||
private getMethod(
|
private getMethod(
|
||||||
type: 'listSearch',
|
type: 'localResourceMapping',
|
||||||
methodName: string,
|
methodName: string,
|
||||||
nodeType: INodeType,
|
nodeType: INodeType,
|
||||||
): (
|
): LocalResourceMappingMethod;
|
||||||
this: ILoadOptionsFunctions,
|
private getMethod(type: 'listSearch', methodName: string, nodeType: INodeType): ListSearchMethod;
|
||||||
filter?: string | undefined,
|
|
||||||
paginationToken?: string | undefined,
|
|
||||||
) => Promise<INodeListSearchResult>;
|
|
||||||
private getMethod(
|
private getMethod(
|
||||||
type: 'loadOptions',
|
type: 'loadOptions',
|
||||||
methodName: string,
|
methodName: string,
|
||||||
nodeType: INodeType,
|
nodeType: INodeType,
|
||||||
): (this: ILoadOptionsFunctions) => Promise<INodePropertyOptions[]>;
|
): LoadOptionsMethod;
|
||||||
private getMethod(
|
private getMethod(
|
||||||
type: 'actionHandler',
|
type: 'actionHandler',
|
||||||
methodName: string,
|
methodName: string,
|
||||||
nodeType: INodeType,
|
nodeType: INodeType,
|
||||||
): (this: ILoadOptionsFunctions, payload?: string) => Promise<NodeParameterValueType>;
|
): ActionHandlerMethod;
|
||||||
|
|
||||||
private getMethod(
|
private getMethod(
|
||||||
type: 'resourceMapping' | 'listSearch' | 'loadOptions' | 'actionHandler',
|
type:
|
||||||
|
| 'resourceMapping'
|
||||||
|
| 'localResourceMapping'
|
||||||
|
| 'listSearch'
|
||||||
|
| 'loadOptions'
|
||||||
|
| 'actionHandler',
|
||||||
methodName: string,
|
methodName: string,
|
||||||
nodeType: INodeType,
|
nodeType: INodeType,
|
||||||
) {
|
): NodeMethod {
|
||||||
const method = nodeType.methods?.[type]?.[methodName];
|
const method = nodeType.methods?.[type]?.[methodName] as NodeMethod;
|
||||||
if (typeof method !== 'function') {
|
if (typeof method !== 'function') {
|
||||||
throw new ApplicationError('Node type does not have method defined', {
|
throw new ApplicationError('Node type does not have method defined', {
|
||||||
tags: { nodeType: nodeType.description.name },
|
tags: { nodeType: nodeType.description.name },
|
||||||
|
@ -253,4 +298,16 @@ export class DynamicNodeParametersService {
|
||||||
const node = workflow.nodes['Temp-Node'];
|
const node = workflow.nodes['Temp-Node'];
|
||||||
return new LoadOptionsContext(workflow, node, additionalData, path);
|
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>;
|
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
|
* AIParametersParser
|
||||||
*
|
*
|
||||||
|
|
|
@ -1197,7 +1197,7 @@ export class WorkflowExecute {
|
||||||
});
|
});
|
||||||
if (workflowIssues !== null) {
|
if (workflowIssues !== null) {
|
||||||
throw new WorkflowOperationError(
|
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 { ExecuteSingleContext } from './execute-single-context';
|
||||||
export { HookContext } from './hook-context';
|
export { HookContext } from './hook-context';
|
||||||
export { LoadOptionsContext } from './load-options-context';
|
export { LoadOptionsContext } from './load-options-context';
|
||||||
|
export { LocalLoadOptionsContext } from './local-load-options-context';
|
||||||
export { PollContext } from './poll-context';
|
export { PollContext } from './poll-context';
|
||||||
// eslint-disable-next-line import/no-cycle
|
// eslint-disable-next-line import/no-cycle
|
||||||
export { SupplyDataContext } from './supply-data-context';
|
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) {
|
if (schemaEntry?.type) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
||||||
const validationResult = validateFieldType(key, resolvedValue, schemaEntry.type, {
|
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,
|
valueOptions: schemaEntry.options,
|
||||||
|
strict: !resourceMapperField.attemptToConvertTypes,
|
||||||
|
parseStrings: !!resourceMapperField.convertFieldsToString,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!validationResult.valid) {
|
if (!validationResult.valid) {
|
||||||
|
if (!resourceMapperField.ignoreTypeMismatchErrors) {
|
||||||
return { ...validationResult, fieldName: key };
|
return { ...validationResult, fieldName: key };
|
||||||
|
} else {
|
||||||
|
paramValues[key] = resolvedValue;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// If it's valid, set the casted value
|
// If it's valid, set the casted value
|
||||||
paramValues[key] = validationResult.newValue;
|
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(
|
export async function getNodeParameterActionResult(
|
||||||
context: IRestApiContext,
|
context: IRestApiContext,
|
||||||
sendData: DynamicNodeParameters.ActionResultRequest,
|
sendData: DynamicNodeParameters.ActionResultRequest,
|
||||||
|
|
|
@ -1431,6 +1431,7 @@ onUpdated(async () => {
|
||||||
:key="option.value.toString()"
|
:key="option.value.toString()"
|
||||||
:value="option.value"
|
:value="option.value"
|
||||||
:label="getOptionsOptionDisplayName(option)"
|
:label="getOptionsOptionDisplayName(option)"
|
||||||
|
data-test-id="parameter-input-item"
|
||||||
>
|
>
|
||||||
<div class="list-option">
|
<div class="list-option">
|
||||||
<div
|
<div
|
||||||
|
|
|
@ -74,6 +74,27 @@ describe('ResourceMapper.vue', () => {
|
||||||
expect(queryByTestId('matching-column-select')).not.toBeInTheDocument();
|
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 () => {
|
it('renders multi-key match selector properly', async () => {
|
||||||
const { container, getByTestId } = renderComponent(
|
const { container, getByTestId } = renderComponent(
|
||||||
{
|
{
|
||||||
|
@ -201,7 +222,7 @@ describe('ResourceMapper.vue', () => {
|
||||||
expect(
|
expect(
|
||||||
getByText('Look for incoming data that matches the foos in the service'),
|
getByText('Look for incoming data that matches the foos in the service'),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(getByText('Foos to Match On')).toBeInTheDocument();
|
expect(getByText('Foos to match on')).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
getByText(
|
getByText(
|
||||||
'The foos to use when matching rows in the service to the input items of this node. Usually an ID.',
|
'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,
|
parseResourceMapperFieldName,
|
||||||
} from '@/utils/nodeTypesUtils';
|
} from '@/utils/nodeTypesUtils';
|
||||||
import { useNodeSpecificationValues } from '@/composables/useNodeSpecificationValues';
|
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';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
@ -37,11 +44,13 @@ interface Props {
|
||||||
refreshInProgress: boolean;
|
refreshInProgress: boolean;
|
||||||
teleported?: boolean;
|
teleported?: boolean;
|
||||||
isReadOnly?: boolean;
|
isReadOnly?: boolean;
|
||||||
|
isDataStale?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
teleported: true,
|
teleported: true,
|
||||||
isReadOnly: false,
|
isReadOnly: false,
|
||||||
|
isDataStale: false,
|
||||||
});
|
});
|
||||||
const FORCE_TEXT_INPUT_FOR_TYPES: FieldType[] = ['time', 'object', 'array'];
|
const FORCE_TEXT_INPUT_FOR_TYPES: FieldType[] = ['time', 'object', 'array'];
|
||||||
|
|
||||||
|
@ -310,6 +319,27 @@ defineExpose({
|
||||||
:value="props.paramValue"
|
:value="props.paramValue"
|
||||||
@update:model-value="onParameterActionSelected"
|
@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>
|
</template>
|
||||||
</N8nInputLabel>
|
</N8nInputLabel>
|
||||||
<div v-if="orderedFields.length === 0" class="mt-3xs mb-xs">
|
<div v-if="orderedFields.length === 0" class="mt-3xs mb-xs">
|
||||||
|
@ -360,7 +390,7 @@ defineExpose({
|
||||||
:title="
|
:title="
|
||||||
locale.baseText('resourceMapper.removeField', {
|
locale.baseText('resourceMapper.removeField', {
|
||||||
interpolate: {
|
interpolate: {
|
||||||
fieldWord: singularFieldWordCapitalized,
|
fieldWord: singularFieldWord,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
"
|
"
|
||||||
|
@ -391,7 +421,7 @@ defineExpose({
|
||||||
<N8nSelect
|
<N8nSelect
|
||||||
:placeholder="
|
:placeholder="
|
||||||
locale.baseText('resourceMapper.addFieldToSend', {
|
locale.baseText('resourceMapper.addFieldToSend', {
|
||||||
interpolate: { fieldWord: singularFieldWordCapitalized },
|
interpolate: { fieldWord: singularFieldWord },
|
||||||
})
|
})
|
||||||
"
|
"
|
||||||
size="small"
|
size="small"
|
||||||
|
@ -442,4 +472,11 @@ defineExpose({
|
||||||
margin-top: var(--spacing-l);
|
margin-top: var(--spacing-l);
|
||||||
padding: 0 0 0 var(--spacing-s);
|
padding: 0 0 0 var(--spacing-s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.staleDataWarning {
|
||||||
|
display: flex;
|
||||||
|
height: var(--spacing-m);
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--spacing-5xs);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -7,7 +7,9 @@ import type {
|
||||||
INodeParameters,
|
INodeParameters,
|
||||||
INodeProperties,
|
INodeProperties,
|
||||||
INodeTypeDescription,
|
INodeTypeDescription,
|
||||||
|
NodeParameterValueType,
|
||||||
ResourceMapperField,
|
ResourceMapperField,
|
||||||
|
ResourceMapperFields,
|
||||||
ResourceMapperValue,
|
ResourceMapperValue,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { NodeHelpers } 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 MappingModeSelect from './MappingModeSelect.vue';
|
||||||
import MatchingColumnsSelect from './MatchingColumnsSelect.vue';
|
import MatchingColumnsSelect from './MatchingColumnsSelect.vue';
|
||||||
import MappingFields from './MappingFields.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 { isFullExecutionResponse, isResourceMapperValue } from '@/utils/typeGuards';
|
||||||
import { i18n as locale } from '@/plugins/i18n';
|
import { i18n as locale } from '@/plugins/i18n';
|
||||||
import { useNDVStore } from '@/stores/ndv.store';
|
import { useNDVStore } from '@/stores/ndv.store';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
import { useDocumentVisibility } from '@/composables/useDocumentVisibility';
|
||||||
|
import { N8nButton, N8nCallout } from 'n8n-design-system';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
parameter: INodeProperties;
|
parameter: INodeProperties;
|
||||||
|
@ -42,6 +50,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||||
isReadOnly: false,
|
isReadOnly: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { onDocumentVisible } = useDocumentVisibility();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
valueChanged: [value: IUpdateInformation];
|
valueChanged: [value: IUpdateInformation];
|
||||||
}>();
|
}>();
|
||||||
|
@ -52,11 +62,18 @@ const state = reactive({
|
||||||
value: {},
|
value: {},
|
||||||
matchingColumns: [] as string[],
|
matchingColumns: [] as string[],
|
||||||
schema: [] as ResourceMapperField[],
|
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,
|
} as ResourceMapperValue,
|
||||||
parameterValues: {} as INodeParameters,
|
parameterValues: {} as INodeParameters,
|
||||||
loading: false,
|
loading: false,
|
||||||
refreshInProgress: false, // Shows inline loader when refreshing fields
|
refreshInProgress: false, // Shows inline loader when refreshing fields
|
||||||
loadingError: false,
|
loadingError: false,
|
||||||
|
hasStaleFields: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reload fields to map when dependent parameters change
|
// 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
|
// Reload fields to map when node is executed
|
||||||
watch(
|
watch(
|
||||||
() => workflowsStore.getWorkflowExecution,
|
() => workflowsStore.getWorkflowExecution,
|
||||||
|
@ -97,6 +129,10 @@ onMounted(async () => {
|
||||||
...state.parameterValues,
|
...state.parameterValues,
|
||||||
parameters: props.node.parameters,
|
parameters: props.node.parameters,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (showTypeConversionOptions.value) {
|
||||||
|
state.paramValue.convertFieldsToString = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const params = state.parameterValues.parameters as INodeParameters;
|
const params = state.parameterValues.parameters as INodeParameters;
|
||||||
const parameterName = props.parameter.name;
|
const parameterName = props.parameter.name;
|
||||||
|
@ -138,6 +174,8 @@ onMounted(async () => {
|
||||||
if (!hasSchema) {
|
if (!hasSchema) {
|
||||||
// Only fetch a schema if it's not already set
|
// Only fetch a schema if it's not already set
|
||||||
await initFetching();
|
await initFetching();
|
||||||
|
} else {
|
||||||
|
await checkStaleFields();
|
||||||
}
|
}
|
||||||
// Set default values if this is the first time the parameter is being set
|
// Set default values if this is the first time the parameter is being set
|
||||||
if (!state.paramValue.value) {
|
if (!state.paramValue.value) {
|
||||||
|
@ -161,11 +199,19 @@ const showMappingModeSelect = computed<boolean>(() => {
|
||||||
return props.parameter.typeOptions?.resourceMapper?.supportAutoMap !== false;
|
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>(() => {
|
const showMatchingColumnsSelector = computed<boolean>(() => {
|
||||||
return (
|
return (
|
||||||
!state.loading &&
|
!state.loading &&
|
||||||
props.parameter.typeOptions?.resourceMapper?.mode !== 'add' &&
|
['upsert', 'update'].includes(props.parameter.typeOptions?.resourceMapper?.mode ?? '') &&
|
||||||
state.paramValue.schema.length > 0
|
hasFields.value
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -174,7 +220,7 @@ const showMappingFields = computed<boolean>(() => {
|
||||||
state.paramValue.mappingMode === 'defineBelow' &&
|
state.paramValue.mappingMode === 'defineBelow' &&
|
||||||
!state.loading &&
|
!state.loading &&
|
||||||
!state.loadingError &&
|
!state.loadingError &&
|
||||||
state.paramValue.schema.length > 0 &&
|
hasFields.value &&
|
||||||
hasAvailableMatchingColumns.value
|
hasAvailableMatchingColumns.value
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -190,6 +236,10 @@ const matchingColumns = computed<string[]>(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasAvailableMatchingColumns = computed<boolean>(() => {
|
const hasAvailableMatchingColumns = computed<boolean>(() => {
|
||||||
|
// 'map' mode doesn't require matching columns
|
||||||
|
if (resourceMapperMode.value === 'map') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (resourceMapperMode.value !== 'add' && resourceMapperMode.value !== 'upsert') {
|
if (resourceMapperMode.value !== 'add' && resourceMapperMode.value !== 'upsert') {
|
||||||
return (
|
return (
|
||||||
state.paramValue.schema.filter(
|
state.paramValue.schema.filter(
|
||||||
|
@ -227,10 +277,11 @@ async function initFetching(inlineLoading = false): Promise<void> {
|
||||||
state.loading = true;
|
state.loading = true;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await loadFieldsToMap();
|
await loadAndSetFieldsToMap();
|
||||||
if (!state.paramValue.matchingColumns || state.paramValue.matchingColumns.length === 0) {
|
if (!state.paramValue.matchingColumns || state.paramValue.matchingColumns.length === 0) {
|
||||||
onMatchingColumnsChanged(defaultSelectedMatchingColumns.value);
|
onMatchingColumnsChanged(defaultSelectedMatchingColumns.value);
|
||||||
}
|
}
|
||||||
|
state.hasStaleFields = false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
state.loadingError = true;
|
state.loadingError = true;
|
||||||
} finally {
|
} finally {
|
||||||
|
@ -239,19 +290,13 @@ async function initFetching(inlineLoading = false): Promise<void> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadFieldsToMap(): Promise<void> {
|
const createRequestParams = (methodName: string) => {
|
||||||
if (!props.node) {
|
if (!props.node) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const methodName = props.parameter.typeOptions?.resourceMapper?.resourceMapperMethod;
|
|
||||||
if (typeof methodName !== 'string') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestParams: DynamicNodeParameters.ResourceMapperFieldsRequest = {
|
const requestParams: DynamicNodeParameters.ResourceMapperFieldsRequest = {
|
||||||
nodeTypeAndVersion: {
|
nodeTypeAndVersion: {
|
||||||
name: props.node?.type,
|
name: props.node.type,
|
||||||
version: props.node.typeVersion,
|
version: props.node.typeVersion,
|
||||||
},
|
},
|
||||||
currentNodeParameters: resolveRequiredParameters(
|
currentNodeParameters: resolveRequiredParameters(
|
||||||
|
@ -262,7 +307,38 @@ async function loadFieldsToMap(): Promise<void> {
|
||||||
methodName,
|
methodName,
|
||||||
credentials: props.node.credentials,
|
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) {
|
if (fetchedFields !== null) {
|
||||||
const newSchema = fetchedFields.fields.map((field) => {
|
const newSchema = fetchedFields.fields.map((field) => {
|
||||||
const existingField = state.paramValue.schema.find((f) => f.id === field.id);
|
const existingField = state.paramValue.schema.find((f) => f.id === field.id);
|
||||||
|
@ -531,11 +607,26 @@ defineExpose({
|
||||||
:teleported="teleported"
|
:teleported="teleported"
|
||||||
:refresh-in-progress="state.refreshInProgress"
|
:refresh-in-progress="state.refreshInProgress"
|
||||||
:is-read-only="isReadOnly"
|
:is-read-only="isReadOnly"
|
||||||
|
:is-data-stale="state.hasStaleFields"
|
||||||
@field-value-changed="fieldValueChanged"
|
@field-value-changed="fieldValueChanged"
|
||||||
@remove-field="removeField"
|
@remove-field="removeField"
|
||||||
@add-field="addField"
|
@add-field="addField"
|
||||||
@refresh-field-list="initFetching(true)"
|
@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
|
<N8nNotice
|
||||||
v-if="state.paramValue.mappingMode === 'autoMapInputData' && hasAvailableMatchingColumns"
|
v-if="state.paramValue.mappingMode === 'autoMapInputData' && hasAvailableMatchingColumns"
|
||||||
>
|
>
|
||||||
|
@ -548,5 +639,49 @@ defineExpose({
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
</N8nNotice>
|
</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>
|
</div>
|
||||||
</template>
|
</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: [
|
nodes: [
|
||||||
{
|
{
|
||||||
id: 'c055762a-8fe7-4141-a639-df2372f30060',
|
id: 'c055762a-8fe7-4141-a639-df2372f30060',
|
||||||
name: 'Execute Workflow Trigger',
|
typeVersion: 1.1,
|
||||||
|
name: 'Workflow Input Trigger',
|
||||||
type: 'n8n-nodes-base.executeWorkflowTrigger',
|
type: 'n8n-nodes-base.executeWorkflowTrigger',
|
||||||
position: [260, 340],
|
position: [260, 340],
|
||||||
parameters: {},
|
parameters: {},
|
||||||
|
@ -189,7 +190,7 @@ export const SAMPLE_SUBWORKFLOW_WORKFLOW: WorkflowDataWithTemplateId = {
|
||||||
},
|
},
|
||||||
] as INodeUi[],
|
] as INodeUi[],
|
||||||
connections: {
|
connections: {
|
||||||
'Execute Workflow Trigger': {
|
'Workflow Input Trigger': {
|
||||||
main: [
|
main: [
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
|
|
@ -58,6 +58,7 @@
|
||||||
"generic.yes": "Yes",
|
"generic.yes": "Yes",
|
||||||
"generic.no": "No",
|
"generic.no": "No",
|
||||||
"generic.rating": "Rating",
|
"generic.rating": "Rating",
|
||||||
|
"generic.refresh": "Refresh",
|
||||||
"generic.retry": "Retry",
|
"generic.retry": "Retry",
|
||||||
"generic.error": "Something went wrong",
|
"generic.error": "Something went wrong",
|
||||||
"generic.settings": "Settings",
|
"generic.settings": "Settings",
|
||||||
|
@ -1572,7 +1573,7 @@
|
||||||
"resourceMapper.fetchingFields.message": "Fetching {fieldWord}",
|
"resourceMapper.fetchingFields.message": "Fetching {fieldWord}",
|
||||||
"resourceMapper.fetchingFields.errorMessage": "Can't get {fieldWord}.",
|
"resourceMapper.fetchingFields.errorMessage": "Can't get {fieldWord}.",
|
||||||
"resourceMapper.fetchingFields.noFieldsFound": "No {fieldWord} found in {serviceName}.",
|
"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.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.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",
|
"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.usingToMatch.description": "This {fieldWord} won't be updated and can't be removed, as it's used for matching",
|
||||||
"resourceMapper.removeField": "Remove {fieldWord}",
|
"resourceMapper.removeField": "Remove {fieldWord}",
|
||||||
"resourceMapper.mandatoryField.title": "This {fieldWord} is mandatory and can’t be removed",
|
"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.matching.title": "This {fieldWord} is used for matching and can’t be removed",
|
||||||
"resourceMapper.addAllFields": "Add All {fieldWord}",
|
"resourceMapper.addAllFields": "Add All {fieldWord}",
|
||||||
"resourceMapper.removeAllFields": "Remove All {fieldWord}",
|
"resourceMapper.removeAllFields": "Remove All {fieldWord}",
|
||||||
"resourceMapper.refreshFieldList": "Refresh {fieldWord} List",
|
"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.openSubExecution": "Inspect Sub-Execution {id}",
|
||||||
"runData.openParentExecution": "Inspect Parent Execution {id}",
|
"runData.openParentExecution": "Inspect Parent Execution {id}",
|
||||||
"runData.emptyItemHint": "This is an item, but it's empty.",
|
"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 (
|
const getNodeParameterActionResult = async (
|
||||||
sendData: DynamicNodeParameters.ActionResultRequest,
|
sendData: DynamicNodeParameters.ActionResultRequest,
|
||||||
) => {
|
) => {
|
||||||
|
@ -326,6 +336,7 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => {
|
||||||
visibleNodeTypesByInputConnectionTypeNames,
|
visibleNodeTypesByInputConnectionTypeNames,
|
||||||
isConfigurableNode,
|
isConfigurableNode,
|
||||||
getResourceMapperFields,
|
getResourceMapperFields,
|
||||||
|
getLocalResourceMapperFields,
|
||||||
getNodeParameterActionResult,
|
getNodeParameterActionResult,
|
||||||
getResourceLocatorResults,
|
getResourceLocatorResults,
|
||||||
getNodeParameterOptions,
|
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 = (
|
export const isMatchingField = (
|
||||||
field: string,
|
field: string,
|
||||||
matchingFields: 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';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
import { getWorkflowInfo } from './GenericFunctions';
|
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 {
|
export class ExecuteWorkflow implements INodeType {
|
||||||
description: INodeTypeDescription = {
|
description: INodeTypeDescription = {
|
||||||
displayName: 'Execute Workflow',
|
displayName: 'Execute Workflow',
|
||||||
|
@ -17,7 +20,7 @@ export class ExecuteWorkflow implements INodeType {
|
||||||
icon: 'fa:sign-in-alt',
|
icon: 'fa:sign-in-alt',
|
||||||
iconColor: 'orange-red',
|
iconColor: 'orange-red',
|
||||||
group: ['transform'],
|
group: ['transform'],
|
||||||
version: [1, 1.1],
|
version: [1, 1.1, 1.2],
|
||||||
subtitle: '={{"Workflow: " + $parameter["workflowId"]}}',
|
subtitle: '={{"Workflow: " + $parameter["workflowId"]}}',
|
||||||
description: 'Execute another workflow',
|
description: 'Execute another workflow',
|
||||||
defaults: {
|
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',
|
displayName: 'Source',
|
||||||
name: 'source',
|
name: 'source',
|
||||||
|
@ -68,6 +78,27 @@ export class ExecuteWorkflow implements INodeType {
|
||||||
],
|
],
|
||||||
default: 'database',
|
default: 'database',
|
||||||
description: 'Where to get the workflow to execute from',
|
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',
|
name: 'executeWorkflowNotice',
|
||||||
type: 'notice',
|
type: 'notice',
|
||||||
default: '',
|
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',
|
displayName: 'Mode',
|
||||||
|
@ -206,10 +274,16 @@ export class ExecuteWorkflow implements INodeType {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
methods = {
|
||||||
|
localResourceMapping: {
|
||||||
|
loadWorkflowInputMappings,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||||
const source = this.getNodeParameter('source', 0) as string;
|
const source = this.getNodeParameter('source', 0) as string;
|
||||||
const mode = this.getNodeParameter('mode', 0, false) 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 workflowProxy = this.getWorkflowDataProxy(0);
|
||||||
const currentWorkflowId = workflowProxy.$workflow.id as string;
|
const currentWorkflowId = workflowProxy.$workflow.id as string;
|
|
@ -3,11 +3,16 @@ import { NodeOperationError, jsonParse } from 'n8n-workflow';
|
||||||
import type {
|
import type {
|
||||||
IExecuteFunctions,
|
IExecuteFunctions,
|
||||||
IExecuteWorkflowInfo,
|
IExecuteWorkflowInfo,
|
||||||
|
ILoadOptionsFunctions,
|
||||||
INodeParameterResourceLocator,
|
INodeParameterResourceLocator,
|
||||||
IRequestOptions,
|
IRequestOptions,
|
||||||
} from 'n8n-workflow';
|
} 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 workflowInfo: IExecuteWorkflowInfo = {};
|
||||||
const nodeVersion = this.getNode().typeVersion;
|
const nodeVersion = this.getNode().typeVersion;
|
||||||
if (source === 'database') {
|
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/ErrorTrigger/ErrorTrigger.node.js",
|
||||||
"dist/nodes/Eventbrite/EventbriteTrigger.node.js",
|
"dist/nodes/Eventbrite/EventbriteTrigger.node.js",
|
||||||
"dist/nodes/ExecuteCommand/ExecuteCommand.node.js",
|
"dist/nodes/ExecuteCommand/ExecuteCommand.node.js",
|
||||||
"dist/nodes/ExecuteWorkflow/ExecuteWorkflow.node.js",
|
"dist/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.js",
|
||||||
"dist/nodes/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.js",
|
"dist/nodes/ExecuteWorkflow/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.js",
|
||||||
"dist/nodes/ExecutionData/ExecutionData.node.js",
|
"dist/nodes/ExecutionData/ExecutionData.node.js",
|
||||||
"dist/nodes/Facebook/FacebookGraphApi.node.js",
|
"dist/nodes/Facebook/FacebookGraphApi.node.js",
|
||||||
"dist/nodes/Facebook/FacebookTrigger.node.js",
|
"dist/nodes/Facebook/FacebookTrigger.node.js",
|
||||||
|
@ -867,6 +867,7 @@
|
||||||
"fast-glob": "catalog:",
|
"fast-glob": "catalog:",
|
||||||
"fflate": "0.7.4",
|
"fflate": "0.7.4",
|
||||||
"get-system-fonts": "2.0.2",
|
"get-system-fonts": "2.0.2",
|
||||||
|
"generate-schema": "2.6.0",
|
||||||
"gm": "1.25.0",
|
"gm": "1.25.0",
|
||||||
"html-to-text": "9.0.5",
|
"html-to-text": "9.0.5",
|
||||||
"iconv-lite": "catalog:",
|
"iconv-lite": "catalog:",
|
||||||
|
|
|
@ -8,7 +8,8 @@
|
||||||
"credentials/**/*.ts",
|
"credentials/**/*.ts",
|
||||||
"nodes/**/*.ts",
|
"nodes/**/*.ts",
|
||||||
"nodes/**/*.json",
|
"nodes/**/*.json",
|
||||||
"credentials/translations/**/*.json"
|
"credentials/translations/**/*.json",
|
||||||
|
"types/**/*.ts"
|
||||||
],
|
],
|
||||||
"exclude": ["nodes/**/*.test.ts", "credentials/**/*.test.ts", "test/**"]
|
"exclude": ["nodes/**/*.test.ts", "credentials/**/*.test.ts", "test/**"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,7 +10,13 @@
|
||||||
"noImplicitReturns": false,
|
"noImplicitReturns": false,
|
||||||
"useUnknownInCatchVariables": false
|
"useUnknownInCatchVariables": false
|
||||||
},
|
},
|
||||||
"include": ["credentials/**/*.ts", "nodes/**/*.ts", "test/**/*.ts", "utils/**/*.ts"],
|
"include": [
|
||||||
|
"credentials/**/*.ts",
|
||||||
|
"nodes/**/*.ts",
|
||||||
|
"test/**/*.ts",
|
||||||
|
"utils/**/*.ts",
|
||||||
|
"types/**/*.ts"
|
||||||
|
],
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "../@n8n/imap/tsconfig.build.json" },
|
{ "path": "../@n8n/imap/tsconfig.build.json" },
|
||||||
{ "path": "../workflow/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,
|
options?: IGetNodeParameterOptions,
|
||||||
): NodeParameterValueType | object | undefined;
|
): NodeParameterValueType | object | undefined;
|
||||||
getCurrentNodeParameters(): INodeParameters | undefined;
|
getCurrentNodeParameters(): INodeParameters | undefined;
|
||||||
|
|
||||||
helpers: RequestHelperFunctions & SSHTunnelFunctions;
|
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
|
export interface IPollFunctions
|
||||||
extends FunctionsBaseWithRequiredKeys<'getMode' | 'getActivationMode'> {
|
extends FunctionsBaseWithRequiredKeys<'getMode' | 'getActivationMode'> {
|
||||||
__emit(
|
__emit(
|
||||||
|
@ -1293,14 +1307,18 @@ export interface INodePropertyTypeOptions {
|
||||||
resourceMapper?: ResourceMapperTypeOptions;
|
resourceMapper?: ResourceMapperTypeOptions;
|
||||||
filter?: FilterTypeOptions;
|
filter?: FilterTypeOptions;
|
||||||
assignment?: AssignmentTypeOptions;
|
assignment?: AssignmentTypeOptions;
|
||||||
|
minRequiredFields?: number; // Supported by: fixedCollection
|
||||||
|
maxAllowedFields?: number; // Supported by: fixedCollection
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResourceMapperTypeOptions {
|
export interface ResourceMapperTypeOptionsBase {
|
||||||
resourceMapperMethod: string;
|
mode: 'add' | 'update' | 'upsert' | 'map';
|
||||||
mode: 'add' | 'update' | 'upsert';
|
|
||||||
valuesLabel?: string;
|
valuesLabel?: string;
|
||||||
fieldWords?: { singular: string; plural: string };
|
fieldWords?: {
|
||||||
|
singular: string;
|
||||||
|
plural: string;
|
||||||
|
};
|
||||||
addAllFields?: boolean;
|
addAllFields?: boolean;
|
||||||
noFieldsError?: string;
|
noFieldsError?: string;
|
||||||
multiKeyMatch?: boolean;
|
multiKeyMatch?: boolean;
|
||||||
|
@ -1310,8 +1328,23 @@ export interface ResourceMapperTypeOptions {
|
||||||
description?: string;
|
description?: string;
|
||||||
hint?: 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[]];
|
type NonEmptyArray<T> = [T, ...T[]];
|
||||||
|
|
||||||
export type FilterTypeCombinator = 'and' | 'or';
|
export type FilterTypeCombinator = 'and' | 'or';
|
||||||
|
@ -1583,6 +1616,9 @@ export interface INodeType {
|
||||||
resourceMapping?: {
|
resourceMapping?: {
|
||||||
[functionName: string]: (this: ILoadOptionsFunctions) => Promise<ResourceMapperFields>;
|
[functionName: string]: (this: ILoadOptionsFunctions) => Promise<ResourceMapperFields>;
|
||||||
};
|
};
|
||||||
|
localResourceMapping?: {
|
||||||
|
[functionName: string]: (this: ILocalLoadOptionsFunctions) => Promise<ResourceMapperFields>;
|
||||||
|
};
|
||||||
actionHandler?: {
|
actionHandler?: {
|
||||||
[functionName: string]: (
|
[functionName: string]: (
|
||||||
this: ILoadOptionsFunctions,
|
this: ILoadOptionsFunctions,
|
||||||
|
@ -2651,6 +2687,9 @@ export type ResourceMapperValue = {
|
||||||
value: { [key: string]: string | number | boolean | null } | null;
|
value: { [key: string]: string | number | boolean | null } | null;
|
||||||
matchingColumns: string[];
|
matchingColumns: string[];
|
||||||
schema: ResourceMapperField[];
|
schema: ResourceMapperField[];
|
||||||
|
ignoreTypeMismatchErrors: boolean;
|
||||||
|
attemptToConvertTypes: boolean;
|
||||||
|
convertFieldsToString: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FilterOperatorType =
|
export type FilterOperatorType =
|
||||||
|
|
|
@ -1568,7 +1568,7 @@ export function getParameterIssues(
|
||||||
data: option as INodeProperties,
|
data: option as INodeProperties,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (nodeProperties.type === 'fixedCollection') {
|
} else if (nodeProperties.type === 'fixedCollection' && isDisplayed) {
|
||||||
basePath = basePath ? `${basePath}.` : `${nodeProperties.name}.`;
|
basePath = basePath ? `${basePath}.` : `${nodeProperties.name}.`;
|
||||||
|
|
||||||
let propertyOptions: INodePropertyCollection;
|
let propertyOptions: INodePropertyCollection;
|
||||||
|
@ -1579,6 +1579,24 @@ export function getParameterIssues(
|
||||||
propertyOptions.name,
|
propertyOptions.name,
|
||||||
basePath.slice(0, -1),
|
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) {
|
if (value === undefined) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
|
@ -974,7 +974,12 @@ export class WorkflowDataProxy {
|
||||||
type: 'no_execution_data',
|
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 = {
|
const base = {
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import {
|
import {
|
||||||
NodeConnectionType,
|
NodeConnectionType,
|
||||||
|
type INodeIssues,
|
||||||
type INode,
|
type INode,
|
||||||
type INodeParameters,
|
type INodeParameters,
|
||||||
type INodeProperties,
|
type INodeProperties,
|
||||||
|
@ -11,6 +12,7 @@ import {
|
||||||
getNodeHints,
|
getNodeHints,
|
||||||
isSubNodeType,
|
isSubNodeType,
|
||||||
applyDeclarativeNodeOptionParameters,
|
applyDeclarativeNodeOptionParameters,
|
||||||
|
getParameterIssues,
|
||||||
} from '@/NodeHelpers';
|
} from '@/NodeHelpers';
|
||||||
import type { Workflow } from '@/Workflow';
|
import type { Workflow } from '@/Workflow';
|
||||||
|
|
||||||
|
@ -3607,4 +3609,590 @@ describe('NodeHelpers', () => {
|
||||||
expect(nodeType.description.properties).toEqual([]);
|
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:
|
fflate:
|
||||||
specifier: 0.7.4
|
specifier: 0.7.4
|
||||||
version: 0.7.4
|
version: 0.7.4
|
||||||
|
generate-schema:
|
||||||
|
specifier: 2.6.0
|
||||||
|
version: 2.6.0
|
||||||
get-system-fonts:
|
get-system-fonts:
|
||||||
specifier: 2.0.2
|
specifier: 2.0.2
|
||||||
version: 2.0.2
|
version: 2.0.2
|
||||||
|
|
Loading…
Reference in a new issue