diff --git a/cypress/e2e/48-subworkflow-inputs.cy.ts b/cypress/e2e/48-subworkflow-inputs.cy.ts new file mode 100644 index 0000000000..0e2755b9f0 --- /dev/null +++ b/cypress/e2e/48-subworkflow-inputs.cy.ts @@ -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) { + 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 + 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'); + }); +}); diff --git a/cypress/fixtures/Test_Subworkflow-Inputs.json b/cypress/fixtures/Test_Subworkflow-Inputs.json new file mode 100644 index 0000000000..aeb4d601fd --- /dev/null +++ b/cypress/fixtures/Test_Subworkflow-Inputs.json @@ -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": [] + } + ] + } +} diff --git a/cypress/pages/ndv.ts b/cypress/pages/ndv.ts index 4550da8e2a..1926ef0ad1 100644 --- a/cypress/pages/ndv.ts +++ b/cypress/pages/ndv.ts @@ -320,6 +320,11 @@ export class NDV extends BasePage { addItemToFixedCollection: (paramName: string) => { this.getters.fixedCollectionParameter(paramName).getByTestId('fixed-collection-add').click(); }, + typeIntoFixedCollectionItem: (fixedCollectionName: string, index: number, content: string) => { + this.getters.fixedCollectionParameter(fixedCollectionName).within(() => { + cy.getByTestId('parameter-input').eq(index).type(content); + }); + }, dragMainPanelToLeft: () => { cy.drag('[data-test-id=panel-drag-button]', [-1000, 0], { moveTwice: true }); }, diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts index 227481b65c..de7abf6a8b 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts @@ -1,567 +1,42 @@ -import type { CallbackManagerForToolRun } from '@langchain/core/callbacks/manager'; -import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools'; -import type { JSONSchema7 } from 'json-schema'; -import get from 'lodash/get'; -import isObject from 'lodash/isObject'; -import type { SetField, SetNodeOptions } from 'n8n-nodes-base/dist/nodes/Set/v2/helpers/interfaces'; -import * as manual from 'n8n-nodes-base/dist/nodes/Set/v2/manual.mode'; -import type { - IExecuteWorkflowInfo, - INodeExecutionData, - INodeType, - INodeTypeDescription, - IWorkflowBase, - ISupplyDataFunctions, - SupplyData, - ExecutionError, - ExecuteWorkflowData, - IDataObject, - INodeParameterResourceLocator, - ITaskMetadata, -} from 'n8n-workflow'; -import { NodeConnectionType, NodeOperationError, jsonParse } from 'n8n-workflow'; +import type { IVersionedNodeType, INodeTypeBaseDescription } from 'n8n-workflow'; +import { VersionedNodeType } from 'n8n-workflow'; -import { jsonSchemaExampleField, schemaTypeField, inputSchemaField } from '@utils/descriptions'; -import { convertJsonSchemaToZod, generateSchema } from '@utils/schemaParsing'; -import { getConnectionHintNoticeField } from '@utils/sharedFields'; +import { ToolWorkflowV1 } from './v1/ToolWorkflowV1.node'; +import { ToolWorkflowV2 } from './v2/ToolWorkflowV2.node'; -import type { DynamicZodObject } from '../../../types/zod.types'; - -export class ToolWorkflow implements INodeType { - description: INodeTypeDescription = { - displayName: 'Call n8n Workflow Tool', - name: 'toolWorkflow', - icon: 'fa:network-wired', - iconColor: 'black', - group: ['transform'], - version: [1, 1.1, 1.2, 1.3], - description: 'Uses another n8n workflow as a tool. Allows packaging any n8n node(s) as a tool.', - defaults: { - name: 'Call n8n Workflow Tool', - }, - codex: { - categories: ['AI'], - subcategories: { - AI: ['Tools'], - Tools: ['Recommended Tools'], - }, - resources: { - primaryDocumentation: [ - { - url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolworkflow/', - }, - ], - }, - }, - // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node - inputs: [], - // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiTool], - outputNames: ['Tool'], - properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiAgent]), - { - displayName: - 'See an example of a workflow to suggest meeting slots using AI here.', - name: 'noticeTemplateExample', - type: 'notice', - default: '', - }, - { - displayName: 'Name', - name: 'name', - type: 'string', - default: '', - placeholder: 'My_Color_Tool', - displayOptions: { - show: { - '@version': [1], - }, +export class ToolWorkflow extends VersionedNodeType { + constructor() { + const baseDescription: INodeTypeBaseDescription = { + displayName: 'Call n8n Sub-Workflow Tool', + name: 'toolWorkflow', + icon: 'fa:network-wired', + group: ['transform'], + description: + 'Uses another n8n workflow as a tool. Allows packaging any n8n node(s) as a tool.', + codex: { + categories: ['AI'], + subcategories: { + AI: ['Tools'], + Tools: ['Recommended Tools'], }, - }, - { - displayName: 'Name', - name: 'name', - type: 'string', - default: '', - placeholder: 'e.g. My_Color_Tool', - validateType: 'string-alphanumeric', - description: - 'The name of the function to be called, could contain letters, numbers, and underscores only', - displayOptions: { - show: { - '@version': [{ _cnd: { gte: 1.1 } }], - }, - }, - }, - { - displayName: 'Description', - name: 'description', - type: 'string', - default: '', - placeholder: - 'Call this tool to get a random color. The input should be a string with comma separted names of colors to exclude.', - typeOptions: { - rows: 3, - }, - }, - - { - displayName: - 'This tool will call the workflow you define below, and look in the last node for the response. The workflow needs to start with an Execute Workflow trigger', - name: 'executeNotice', - type: 'notice', - default: '', - }, - - { - displayName: 'Source', - name: 'source', - type: 'options', - options: [ - { - name: 'Database', - value: 'database', - description: 'Load the workflow from the database by ID', - }, - { - name: 'Define Below', - value: 'parameter', - description: 'Pass the JSON code of a workflow', - }, - ], - default: 'database', - description: 'Where to get the workflow to execute from', - }, - - // ---------------------------------- - // source:database - // ---------------------------------- - { - displayName: 'Workflow ID', - name: 'workflowId', - type: 'string', - displayOptions: { - show: { - source: ['database'], - '@version': [{ _cnd: { lte: 1.1 } }], - }, - }, - default: '', - required: true, - description: 'The workflow to execute', - hint: 'Can be found in the URL of the workflow', - }, - - { - displayName: 'Workflow', - name: 'workflowId', - type: 'workflowSelector', - displayOptions: { - show: { - source: ['database'], - '@version': [{ _cnd: { gte: 1.2 } }], - }, - }, - default: '', - required: true, - }, - - // ---------------------------------- - // source:parameter - // ---------------------------------- - { - displayName: 'Workflow JSON', - name: 'workflowJson', - type: 'json', - typeOptions: { - rows: 10, - }, - displayOptions: { - show: { - source: ['parameter'], - }, - }, - default: '\n\n\n\n\n\n\n\n\n', - required: true, - description: 'The workflow JSON code to execute', - }, - // ---------------------------------- - // For all - // ---------------------------------- - { - displayName: 'Field to Return', - name: 'responsePropertyName', - type: 'string', - default: 'response', - required: true, - hint: 'The field in the last-executed node of the workflow that contains the response', - description: - 'Where to find the data that this tool should return. n8n will look in the output of the last-executed node of the workflow for a field with this name, and return its value.', - displayOptions: { - show: { - '@version': [{ _cnd: { lt: 1.3 } }], - }, - }, - }, - { - displayName: 'Extra Workflow Inputs', - name: 'fields', - placeholder: 'Add Value', - type: 'fixedCollection', - description: - "These will be output by the 'execute workflow' trigger of the workflow being called", - typeOptions: { - multipleValues: true, - sortable: true, - }, - default: {}, - options: [ - { - name: 'values', - displayName: 'Values', - values: [ - { - displayName: 'Name', - name: 'name', - type: 'string', - default: '', - placeholder: 'e.g. fieldName', - description: - 'Name of the field to set the value of. Supports dot-notation. Example: data.person[0].name.', - requiresDataPath: 'single', - }, - { - displayName: 'Type', - name: 'type', - type: 'options', - description: 'The field value type', - // eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items - options: [ - { - name: 'String', - value: 'stringValue', - }, - { - name: 'Number', - value: 'numberValue', - }, - { - name: 'Boolean', - value: 'booleanValue', - }, - { - name: 'Array', - value: 'arrayValue', - }, - { - name: 'Object', - value: 'objectValue', - }, - ], - default: 'stringValue', - }, - { - displayName: 'Value', - name: 'stringValue', - type: 'string', - default: '', - displayOptions: { - show: { - type: ['stringValue'], - }, - }, - validateType: 'string', - ignoreValidationDuringExecution: true, - }, - { - displayName: 'Value', - name: 'numberValue', - type: 'string', - default: '', - displayOptions: { - show: { - type: ['numberValue'], - }, - }, - validateType: 'number', - ignoreValidationDuringExecution: true, - }, - { - displayName: 'Value', - name: 'booleanValue', - type: 'options', - default: 'true', - options: [ - { - name: 'True', - value: 'true', - }, - { - name: 'False', - value: 'false', - }, - ], - displayOptions: { - show: { - type: ['booleanValue'], - }, - }, - validateType: 'boolean', - ignoreValidationDuringExecution: true, - }, - { - displayName: 'Value', - name: 'arrayValue', - type: 'string', - default: '', - placeholder: 'e.g. [ arrayItem1, arrayItem2, arrayItem3 ]', - displayOptions: { - show: { - type: ['arrayValue'], - }, - }, - validateType: 'array', - ignoreValidationDuringExecution: true, - }, - { - displayName: 'Value', - name: 'objectValue', - type: 'json', - default: '={}', - typeOptions: { - rows: 2, - }, - displayOptions: { - show: { - type: ['objectValue'], - }, - }, - validateType: 'object', - ignoreValidationDuringExecution: true, - }, - ], - }, - ], - }, - // ---------------------------------- - // Output Parsing - // ---------------------------------- - { - displayName: 'Specify Input Schema', - name: 'specifyInputSchema', - type: 'boolean', - description: - 'Whether to specify the schema for the function. This would require the LLM to provide the input in the correct format and would validate it against the schema.', - noDataExpression: true, - default: false, - }, - { ...schemaTypeField, displayOptions: { show: { specifyInputSchema: [true] } } }, - jsonSchemaExampleField, - inputSchemaField, - ], - }; - - async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { - 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 => { - const source = this.getNodeParameter('source', itemIndex) as string; - const workflowInfo: IExecuteWorkflowInfo = {}; - if (source === 'database') { - // Read workflow from database - const nodeVersion = this.getNode().typeVersion; - if (nodeVersion <= 1.1) { - workflowInfo.id = this.getNodeParameter('workflowId', itemIndex) as string; - } else { - const { value } = this.getNodeParameter( - 'workflowId', - itemIndex, - {}, - ) as INodeParameterResourceLocator; - workflowInfo.id = value as string; - } - - subWorkflowId = workflowInfo.id; - } else if (source === 'parameter') { - // Read workflow from parameter - const workflowJson = this.getNodeParameter('workflowJson', itemIndex) as string; - try { - workflowInfo.code = JSON.parse(workflowJson) as IWorkflowBase; - - // subworkflow is same as parent workflow - subWorkflowId = workflowProxy.$workflow.id; - } catch (error) { - throw new NodeOperationError( - this.getNode(), - `The provided workflow is not valid JSON: "${(error as Error).message}"`, + resources: { + primaryDocumentation: [ { - itemIndex, + url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolworkflow/', }, - ); - } - } - - const rawData: IDataObject = { query }; - - const workflowFieldsJson = this.getNodeParameter('fields.values', itemIndex, [], { - rawExpressions: true, - }) as SetField[]; - - // Copied from Set Node v2 - for (const entry of workflowFieldsJson) { - if (entry.type === 'objectValue' && (entry.objectValue as string).startsWith('=')) { - rawData[entry.name] = (entry.objectValue as string).replace(/^=+/, ''); - } - } - - const options: SetNodeOptions = { - include: 'all', - }; - - const newItem = await manual.execute.call( - this, - { json: { query } }, - itemIndex, - options, - rawData, - this.getNode(), - ); - - const items = [newItem] as INodeExecutionData[]; - - let receivedData: ExecuteWorkflowData; - try { - receivedData = await this.executeWorkflow(workflowInfo, items, runManager?.getChild(), { - parentExecution: { - executionId: workflowProxy.$execution.id, - workflowId: workflowProxy.$workflow.id, - }, - }); - subExecutionId = receivedData.executionId; - } catch (error) { - // Make sure a valid error gets returned that can by json-serialized else it will - // not show up in the frontend - throw new NodeOperationError(this.getNode(), error as Error); - } - - const response: string | undefined = get(receivedData, 'data[0][0].json') as - | string - | undefined; - if (response === undefined) { - throw new NodeOperationError( - this.getNode(), - 'There was an error: "The workflow did not return a response"', - ); - } - - return response; + ], + }, + }, + defaultVersion: 2, }; - const toolHandler = async ( - query: string | IDataObject, - runManager?: CallbackManagerForToolRun, - ): Promise => { - 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(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(inputSchema); - - const zodSchema = convertJsonSchemaToZod(jsonSchema); - - tool = new DynamicStructuredTool({ - schema: zodSchema, - ...functionBase, - }); - } catch (error) { - throw new NodeOperationError( - this.getNode(), - 'Error during parsing of JSON Schema. \n ' + error, - ); - } - } else { - tool = new DynamicTool(functionBase); - } - - return { - response: tool, + const nodeVersions: IVersionedNodeType['nodeVersions'] = { + 1: new ToolWorkflowV1(baseDescription), + 1.1: new ToolWorkflowV1(baseDescription), + 1.2: new ToolWorkflowV1(baseDescription), + 1.3: new ToolWorkflowV1(baseDescription), + 2: new ToolWorkflowV2(baseDescription), }; + super(nodeVersions, baseDescription); } } diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v1/ToolWorkflowV1.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v1/ToolWorkflowV1.node.ts new file mode 100644 index 0000000000..4c33c86b4e --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v1/ToolWorkflowV1.node.ts @@ -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 { + 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 => { + 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 => { + 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(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(inputSchema); + + const zodSchema = convertJsonSchemaToZod(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, + }; + } +} diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v1/versionDescription.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v1/versionDescription.ts new file mode 100644 index 0000000000..da7a0e9815 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v1/versionDescription.ts @@ -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 here.', + 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, + ], +}; diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.node.ts new file mode 100644 index 0000000000..22ca31e4da --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.node.ts @@ -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 { + 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 }; + } +} diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.test.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.test.ts new file mode 100644 index 0000000000..73aa24c6b7 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.test.ts @@ -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 { + 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); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/FromAIParser.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/FromAIParser.ts new file mode 100644 index 0000000000..4b9b6ed58e --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/FromAIParser.ts @@ -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; +} + +// 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 | 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; + } + } +} diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/WorkflowToolService.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/WorkflowToolService.ts new file mode 100644 index 0000000000..2ce3c43556 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/WorkflowToolService.ts @@ -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 { + // 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 => { + 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(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 { + 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 { + 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, + ): Promise { + 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 { + const collectedArguments: FromAIArgument[] = []; + fromAIParser.traverseNodeParameters(this.context.getNode().parameters, collectedArguments); + + const uniqueArgsMap = new Map(); + for (const arg of collectedArguments) { + uniqueArgsMap.set(arg.key, arg); + } + + return Array.from(uniqueArgsMap.values()); + } + + private createZodSchema(args: FromAIArgument[], parser: AIParametersParser): z.ZodObject { + const schemaObj = args.reduce((acc: Record, placeholder) => { + acc[placeholder.key] = parser.generateZodSchema(placeholder); + return acc; + }, {}); + + return z.object(schemaObj).required(); + } +} diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/versionDescription.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/versionDescription.ts new file mode 100644 index 0000000000..469a7d6d4c --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/versionDescription.ts @@ -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 here.', + 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', + }, + ], +}; diff --git a/packages/cli/src/controllers/dynamic-node-parameters.controller.ts b/packages/cli/src/controllers/dynamic-node-parameters.controller.ts index f3df53d95b..2858fd99ca 100644 --- a/packages/cli/src/controllers/dynamic-node-parameters.controller.ts +++ b/packages/cli/src/controllers/dynamic-node-parameters.controller.ts @@ -93,6 +93,22 @@ export class DynamicNodeParametersController { ); } + @Post('/local-resource-mapper-fields') + async getLocalResourceMappingFields(req: DynamicNodeParametersRequest.ResourceMapperFields) { + const { path, methodName, currentNodeParameters, nodeTypeAndVersion } = req.body; + + if (!methodName) throw new BadRequestError('Missing `methodName` in request body'); + + const additionalData = await getBase(req.user.id, currentNodeParameters); + + return await this.service.getLocalResourceMappingFields( + methodName, + path, + additionalData, + nodeTypeAndVersion, + ); + } + @Post('/action-result') async getActionResult( req: DynamicNodeParametersRequest.ActionResult, diff --git a/packages/cli/src/services/dynamic-node-parameters.service.ts b/packages/cli/src/services/dynamic-node-parameters.service.ts index a20d63b5fa..65c40ef0b6 100644 --- a/packages/cli/src/services/dynamic-node-parameters.service.ts +++ b/packages/cli/src/services/dynamic-node-parameters.service.ts @@ -1,4 +1,4 @@ -import { LoadOptionsContext, RoutingNode } from 'n8n-core'; +import { LoadOptionsContext, RoutingNode, LocalLoadOptionsContext } from 'n8n-core'; import type { ILoadOptions, ILoadOptionsFunctions, @@ -17,15 +17,43 @@ import type { INodeTypeNameVersion, NodeParameterValueType, IDataObject, + ILocalLoadOptionsFunctions, } from 'n8n-workflow'; import { Workflow, ApplicationError } from 'n8n-workflow'; import { Service } from 'typedi'; import { NodeTypes } from '@/node-types'; +import { WorkflowLoaderService } from './workflow-loader.service'; + +type LocalResourceMappingMethod = ( + this: ILocalLoadOptionsFunctions, +) => Promise; +type ListSearchMethod = ( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, +) => Promise; +type LoadOptionsMethod = (this: ILoadOptionsFunctions) => Promise; +type ActionHandlerMethod = ( + this: ILoadOptionsFunctions, + payload?: string, +) => Promise; +type ResourceMappingMethod = (this: ILoadOptionsFunctions) => Promise; + +type NodeMethod = + | LocalResourceMappingMethod + | ListSearchMethod + | LoadOptionsMethod + | ActionHandlerMethod + | ResourceMappingMethod; + @Service() export class DynamicNodeParametersService { - constructor(private nodeTypes: NodeTypes) {} + constructor( + private nodeTypes: NodeTypes, + private workflowLoaderService: WorkflowLoaderService, + ) {} /** Returns the available options via a predefined method */ async getOptionsViaMethodName( @@ -40,6 +68,8 @@ export class DynamicNodeParametersService { const method = this.getMethod('loadOptions', methodName, nodeType); const workflow = this.getWorkflow(nodeTypeAndVersion, currentNodeParameters, credentials); const thisArgs = this.getThisArg(path, additionalData, workflow); + // Need to use untyped call since `this` usage is widespread and we don't have `strictBindCallApply` + // enabled in `tsconfig.json` // eslint-disable-next-line @typescript-eslint/no-unsafe-return return method.call(thisArgs); } @@ -157,6 +187,20 @@ export class DynamicNodeParametersService { return method.call(thisArgs); } + /** Returns the available workflow input mapping fields for the ResourceMapper component */ + async getLocalResourceMappingFields( + methodName: string, + path: string, + additionalData: IWorkflowExecuteAdditionalData, + nodeTypeAndVersion: INodeTypeNameVersion, + ): Promise { + const nodeType = this.getNodeType(nodeTypeAndVersion); + const method = this.getMethod('localResourceMapping', methodName, nodeType); + const thisArgs = this.getLocalLoadOptionsContext(path, additionalData); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return method.call(thisArgs); + } + /** Returns the result of the action handler */ async getActionResult( handler: string, @@ -179,33 +223,34 @@ export class DynamicNodeParametersService { type: 'resourceMapping', methodName: string, nodeType: INodeType, - ): (this: ILoadOptionsFunctions) => Promise; + ): ResourceMappingMethod; private getMethod( - type: 'listSearch', + type: 'localResourceMapping', methodName: string, nodeType: INodeType, - ): ( - this: ILoadOptionsFunctions, - filter?: string | undefined, - paginationToken?: string | undefined, - ) => Promise; + ): LocalResourceMappingMethod; + private getMethod(type: 'listSearch', methodName: string, nodeType: INodeType): ListSearchMethod; private getMethod( type: 'loadOptions', methodName: string, nodeType: INodeType, - ): (this: ILoadOptionsFunctions) => Promise; + ): LoadOptionsMethod; private getMethod( type: 'actionHandler', methodName: string, nodeType: INodeType, - ): (this: ILoadOptionsFunctions, payload?: string) => Promise; - + ): ActionHandlerMethod; private getMethod( - type: 'resourceMapping' | 'listSearch' | 'loadOptions' | 'actionHandler', + type: + | 'resourceMapping' + | 'localResourceMapping' + | 'listSearch' + | 'loadOptions' + | 'actionHandler', methodName: string, nodeType: INodeType, - ) { - const method = nodeType.methods?.[type]?.[methodName]; + ): NodeMethod { + const method = nodeType.methods?.[type]?.[methodName] as NodeMethod; if (typeof method !== 'function') { throw new ApplicationError('Node type does not have method defined', { tags: { nodeType: nodeType.description.name }, @@ -253,4 +298,16 @@ export class DynamicNodeParametersService { const node = workflow.nodes['Temp-Node']; return new LoadOptionsContext(workflow, node, additionalData, path); } + + private getLocalLoadOptionsContext( + path: string, + additionalData: IWorkflowExecuteAdditionalData, + ): ILocalLoadOptionsFunctions { + return new LocalLoadOptionsContext( + this.nodeTypes, + additionalData, + path, + this.workflowLoaderService, + ); + } } diff --git a/packages/cli/src/services/workflow-loader.service.ts b/packages/cli/src/services/workflow-loader.service.ts new file mode 100644 index 0000000000..ca1a9ff48c --- /dev/null +++ b/packages/cli/src/services/workflow-loader.service.ts @@ -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 { + const workflow = await this.workflowRepository.findById(workflowId); + + if (!workflow) { + throw new ApplicationError(`Failed to find workflow with ID "${workflowId}"`); + } + + return workflow; + } +} diff --git a/packages/core/src/CreateNodeAsTool.ts b/packages/core/src/CreateNodeAsTool.ts index a84c564210..da34b377df 100644 --- a/packages/core/src/CreateNodeAsTool.ts +++ b/packages/core/src/CreateNodeAsTool.ts @@ -17,6 +17,9 @@ type ParserOptions = { handleToolInvocation: (toolArgs: IDataObject) => Promise; }; +// 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 * diff --git a/packages/core/src/WorkflowExecute.ts b/packages/core/src/WorkflowExecute.ts index 252694fd1f..f340c8da67 100644 --- a/packages/core/src/WorkflowExecute.ts +++ b/packages/core/src/WorkflowExecute.ts @@ -1197,7 +1197,7 @@ export class WorkflowExecute { }); if (workflowIssues !== null) { throw new WorkflowOperationError( - 'The workflow has issues and can for that reason not be executed. Please fix them first.', + 'The workflow has issues and cannot be executed for that reason. Please fix them first.', ); } diff --git a/packages/core/src/node-execution-context/index.ts b/packages/core/src/node-execution-context/index.ts index 64088af72e..c3bcebbd44 100644 --- a/packages/core/src/node-execution-context/index.ts +++ b/packages/core/src/node-execution-context/index.ts @@ -3,6 +3,7 @@ export { ExecuteContext } from './execute-context'; export { ExecuteSingleContext } from './execute-single-context'; export { HookContext } from './hook-context'; export { LoadOptionsContext } from './load-options-context'; +export { LocalLoadOptionsContext } from './local-load-options-context'; export { PollContext } from './poll-context'; // eslint-disable-next-line import/no-cycle export { SupplyDataContext } from './supply-data-context'; diff --git a/packages/core/src/node-execution-context/local-load-options-context.ts b/packages/core/src/node-execution-context/local-load-options-context.ts new file mode 100644 index 0000000000..39456ff966 --- /dev/null +++ b/packages/core/src/node-execution-context/local-load-options-context.ts @@ -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 { + 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; + } +} diff --git a/packages/core/src/node-execution-context/utils/validateValueAgainstSchema.ts b/packages/core/src/node-execution-context/utils/validateValueAgainstSchema.ts index 2792199807..adac8c3a78 100644 --- a/packages/core/src/node-execution-context/utils/validateValueAgainstSchema.ts +++ b/packages/core/src/node-execution-context/utils/validateValueAgainstSchema.ts @@ -53,15 +53,19 @@ const validateResourceMapperValue = ( }; } - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access if (schemaEntry?.type) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const validationResult = validateFieldType(key, resolvedValue, schemaEntry.type, { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access valueOptions: schemaEntry.options, + strict: !resourceMapperField.attemptToConvertTypes, + parseStrings: !!resourceMapperField.convertFieldsToString, }); + if (!validationResult.valid) { - return { ...validationResult, fieldName: key }; + if (!resourceMapperField.ignoreTypeMismatchErrors) { + return { ...validationResult, fieldName: key }; + } else { + paramValues[key] = resolvedValue; + } } else { // If it's valid, set the casted value paramValues[key] = validationResult.newValue; diff --git a/packages/core/src/node-execution-context/workflow-node-context.ts b/packages/core/src/node-execution-context/workflow-node-context.ts new file mode 100644 index 0000000000..18de159e4b --- /dev/null +++ b/packages/core/src/node-execution-context/workflow-node-context.ts @@ -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']; + } + } +} diff --git a/packages/editor-ui/src/api/nodeTypes.ts b/packages/editor-ui/src/api/nodeTypes.ts index f4d516aaef..ec3e2bdba5 100644 --- a/packages/editor-ui/src/api/nodeTypes.ts +++ b/packages/editor-ui/src/api/nodeTypes.ts @@ -59,6 +59,18 @@ export async function getResourceMapperFields( ); } +export async function getLocalResourceMapperFields( + context: IRestApiContext, + sendData: DynamicNodeParameters.ResourceMapperFieldsRequest, +): Promise { + return await makeRestApiRequest( + context, + 'POST', + '/dynamic-node-parameters/local-resource-mapper-fields', + sendData, + ); +} + export async function getNodeParameterActionResult( context: IRestApiContext, sendData: DynamicNodeParameters.ActionResultRequest, diff --git a/packages/editor-ui/src/components/ParameterInput.vue b/packages/editor-ui/src/components/ParameterInput.vue index 1ee841323d..1a5740979d 100644 --- a/packages/editor-ui/src/components/ParameterInput.vue +++ b/packages/editor-ui/src/components/ParameterInput.vue @@ -1431,6 +1431,7 @@ onUpdated(async () => { :key="option.value.toString()" :value="option.value" :label="getOptionsOptionDisplayName(option)" + data-test-id="parameter-input-item" >
{ expect(queryByTestId('matching-column-select')).not.toBeInTheDocument(); }); + it('renders map mode properly', async () => { + const { getByTestId, queryByTestId } = renderComponent( + { + props: { + parameter: { + typeOptions: { + resourceMapper: { + mode: 'map', + }, + }, + }, + }, + }, + { merge: true }, + ); + await waitAllPromises(); + expect(getByTestId('resource-mapper-container')).toBeInTheDocument(); + // This mode doesn't render matching column selector + expect(queryByTestId('matching-column-select')).not.toBeInTheDocument(); + }); + it('renders multi-key match selector properly', async () => { const { container, getByTestId } = renderComponent( { @@ -201,7 +222,7 @@ describe('ResourceMapper.vue', () => { expect( getByText('Look for incoming data that matches the foos in the service'), ).toBeInTheDocument(); - expect(getByText('Foos to Match On')).toBeInTheDocument(); + expect(getByText('Foos to match on')).toBeInTheDocument(); expect( getByText( 'The foos to use when matching rows in the service to the input items of this node. Usually an ID.', diff --git a/packages/editor-ui/src/components/ResourceMapper/MappingFields.vue b/packages/editor-ui/src/components/ResourceMapper/MappingFields.vue index 0b444dd13f..546e33cdc3 100644 --- a/packages/editor-ui/src/components/ResourceMapper/MappingFields.vue +++ b/packages/editor-ui/src/components/ResourceMapper/MappingFields.vue @@ -21,7 +21,14 @@ import { parseResourceMapperFieldName, } from '@/utils/nodeTypesUtils'; import { useNodeSpecificationValues } from '@/composables/useNodeSpecificationValues'; -import { N8nIconButton, N8nInputLabel, N8nOption, N8nSelect, N8nTooltip } from 'n8n-design-system'; +import { + N8nIcon, + N8nIconButton, + N8nInputLabel, + N8nOption, + N8nSelect, + N8nTooltip, +} from 'n8n-design-system'; import { useI18n } from '@/composables/useI18n'; interface Props { @@ -37,11 +44,13 @@ interface Props { refreshInProgress: boolean; teleported?: boolean; isReadOnly?: boolean; + isDataStale?: boolean; } const props = withDefaults(defineProps(), { teleported: true, isReadOnly: false, + isDataStale: false, }); const FORCE_TEXT_INPUT_FOR_TYPES: FieldType[] = ['time', 'object', 'array']; @@ -310,6 +319,27 @@ defineExpose({ :value="props.paramValue" @update:model-value="onParameterActionSelected" /> +
+ + + + + +
@@ -360,7 +390,7 @@ defineExpose({ :title=" locale.baseText('resourceMapper.removeField', { interpolate: { - fieldWord: singularFieldWordCapitalized, + fieldWord: singularFieldWord, }, }) " @@ -391,7 +421,7 @@ defineExpose({ diff --git a/packages/editor-ui/src/components/ResourceMapper/ResourceMapper.vue b/packages/editor-ui/src/components/ResourceMapper/ResourceMapper.vue index b09354ed00..7487f1d8d4 100644 --- a/packages/editor-ui/src/components/ResourceMapper/ResourceMapper.vue +++ b/packages/editor-ui/src/components/ResourceMapper/ResourceMapper.vue @@ -7,7 +7,9 @@ import type { INodeParameters, INodeProperties, INodeTypeDescription, + NodeParameterValueType, ResourceMapperField, + ResourceMapperFields, ResourceMapperValue, } from 'n8n-workflow'; import { NodeHelpers } from 'n8n-workflow'; @@ -15,11 +17,17 @@ import { computed, onMounted, reactive, watch } from 'vue'; import MappingModeSelect from './MappingModeSelect.vue'; import MatchingColumnsSelect from './MatchingColumnsSelect.vue'; import MappingFields from './MappingFields.vue'; -import { fieldCannotBeDeleted, parseResourceMapperFieldName } from '@/utils/nodeTypesUtils'; +import { + fieldCannotBeDeleted, + isResourceMapperFieldListStale, + parseResourceMapperFieldName, +} from '@/utils/nodeTypesUtils'; import { isFullExecutionResponse, isResourceMapperValue } from '@/utils/typeGuards'; import { i18n as locale } from '@/plugins/i18n'; import { useNDVStore } from '@/stores/ndv.store'; import { useWorkflowsStore } from '@/stores/workflows.store'; +import { useDocumentVisibility } from '@/composables/useDocumentVisibility'; +import { N8nButton, N8nCallout } from 'n8n-design-system'; type Props = { parameter: INodeProperties; @@ -42,6 +50,8 @@ const props = withDefaults(defineProps(), { isReadOnly: false, }); +const { onDocumentVisible } = useDocumentVisibility(); + const emit = defineEmits<{ valueChanged: [value: IUpdateInformation]; }>(); @@ -52,11 +62,18 @@ const state = reactive({ value: {}, matchingColumns: [] as string[], schema: [] as ResourceMapperField[], + ignoreTypeMismatchErrors: false, + attemptToConvertTypes: false, + // This should always be true if `showTypeConversionOptions` is provided + // It's used to avoid accepting any value as string without casting it + // Which is the legacy behavior without these type options. + convertFieldsToString: false, } as ResourceMapperValue, parameterValues: {} as INodeParameters, loading: false, refreshInProgress: false, // Shows inline loader when refreshing fields loadingError: false, + hasStaleFields: false, }); // Reload fields to map when dependent parameters change @@ -76,6 +93,21 @@ watch( }, ); +onDocumentVisible(async () => { + await checkStaleFields(); +}); + +async function checkStaleFields(): Promise { + const fetchedFields = await fetchFields(); + if (fetchedFields) { + const isSchemaStale = isResourceMapperFieldListStale( + state.paramValue.schema, + fetchedFields.fields, + ); + state.hasStaleFields = isSchemaStale; + } +} + // Reload fields to map when node is executed watch( () => workflowsStore.getWorkflowExecution, @@ -97,6 +129,10 @@ onMounted(async () => { ...state.parameterValues, parameters: props.node.parameters, }; + + if (showTypeConversionOptions.value) { + state.paramValue.convertFieldsToString = true; + } } const params = state.parameterValues.parameters as INodeParameters; const parameterName = props.parameter.name; @@ -138,6 +174,8 @@ onMounted(async () => { if (!hasSchema) { // Only fetch a schema if it's not already set await initFetching(); + } else { + await checkStaleFields(); } // Set default values if this is the first time the parameter is being set if (!state.paramValue.value) { @@ -161,11 +199,19 @@ const showMappingModeSelect = computed(() => { return props.parameter.typeOptions?.resourceMapper?.supportAutoMap !== false; }); +const showTypeConversionOptions = computed(() => { + return props.parameter.typeOptions?.resourceMapper?.showTypeConversionOptions === true; +}); + +const hasFields = computed(() => { + return state.paramValue.schema.length > 0; +}); + const showMatchingColumnsSelector = computed(() => { return ( !state.loading && - props.parameter.typeOptions?.resourceMapper?.mode !== 'add' && - state.paramValue.schema.length > 0 + ['upsert', 'update'].includes(props.parameter.typeOptions?.resourceMapper?.mode ?? '') && + hasFields.value ); }); @@ -174,7 +220,7 @@ const showMappingFields = computed(() => { state.paramValue.mappingMode === 'defineBelow' && !state.loading && !state.loadingError && - state.paramValue.schema.length > 0 && + hasFields.value && hasAvailableMatchingColumns.value ); }); @@ -190,6 +236,10 @@ const matchingColumns = computed(() => { }); const hasAvailableMatchingColumns = computed(() => { + // 'map' mode doesn't require matching columns + if (resourceMapperMode.value === 'map') { + return true; + } if (resourceMapperMode.value !== 'add' && resourceMapperMode.value !== 'upsert') { return ( state.paramValue.schema.filter( @@ -227,10 +277,11 @@ async function initFetching(inlineLoading = false): Promise { state.loading = true; } try { - await loadFieldsToMap(); + await loadAndSetFieldsToMap(); if (!state.paramValue.matchingColumns || state.paramValue.matchingColumns.length === 0) { onMatchingColumnsChanged(defaultSelectedMatchingColumns.value); } + state.hasStaleFields = false; } catch (error) { state.loadingError = true; } finally { @@ -239,19 +290,13 @@ async function initFetching(inlineLoading = false): Promise { } } -async function loadFieldsToMap(): Promise { +const createRequestParams = (methodName: string) => { if (!props.node) { return; } - - const methodName = props.parameter.typeOptions?.resourceMapper?.resourceMapperMethod; - if (typeof methodName !== 'string') { - return; - } - const requestParams: DynamicNodeParameters.ResourceMapperFieldsRequest = { nodeTypeAndVersion: { - name: props.node?.type, + name: props.node.type, version: props.node.typeVersion, }, currentNodeParameters: resolveRequiredParameters( @@ -262,7 +307,38 @@ async function loadFieldsToMap(): Promise { methodName, credentials: props.node.credentials, }; - const fetchedFields = await nodeTypesStore.getResourceMapperFields(requestParams); + + return requestParams; +}; + +async function fetchFields(): Promise { + 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 { + if (!props.node) { + return; + } + + const fetchedFields = await fetchFields(); + if (fetchedFields !== null) { const newSchema = fetchedFields.fields.map((field) => { const existingField = state.paramValue.schema.find((f) => f.id === field.id); @@ -531,11 +607,26 @@ defineExpose({ :teleported="teleported" :refresh-in-progress="state.refreshInProgress" :is-read-only="isReadOnly" + :is-data-stale="state.hasStaleFields" @field-value-changed="fieldValueChanged" @remove-field="removeField" @add-field="addField" @refresh-field-list="initFetching(true)" /> + + {{ locale.baseText('resourceMapper.staleDataWarning.notice') }} + + @@ -548,5 +639,49 @@ defineExpose({ }) }} +
+ + +
+ + diff --git a/packages/editor-ui/src/composables/useDocumentVisibility.ts b/packages/editor-ui/src/composables/useDocumentVisibility.ts new file mode 100644 index 0000000000..47af96309f --- /dev/null +++ b/packages/editor-ui/src/composables/useDocumentVisibility.ts @@ -0,0 +1,52 @@ +import type { Ref } from 'vue'; +import { ref, onMounted, onUnmounted } from 'vue'; + +type VisibilityHandler = () => void; + +type DocumentVisibilityResult = { + isVisible: Ref; + onDocumentVisible: (handler: VisibilityHandler) => void; + onDocumentHidden: (handler: VisibilityHandler) => void; +}; + +export function useDocumentVisibility(): DocumentVisibilityResult { + const isVisible = ref(!document.hidden); + const visibleHandlers = ref([]); + const hiddenHandlers = ref([]); + + 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, + }; +} diff --git a/packages/editor-ui/src/constants.workflows.ts b/packages/editor-ui/src/constants.workflows.ts index 52a6881df8..2116a471f9 100644 --- a/packages/editor-ui/src/constants.workflows.ts +++ b/packages/editor-ui/src/constants.workflows.ts @@ -175,7 +175,8 @@ export const SAMPLE_SUBWORKFLOW_WORKFLOW: WorkflowDataWithTemplateId = { nodes: [ { id: 'c055762a-8fe7-4141-a639-df2372f30060', - name: 'Execute Workflow Trigger', + typeVersion: 1.1, + name: 'Workflow Input Trigger', type: 'n8n-nodes-base.executeWorkflowTrigger', position: [260, 340], parameters: {}, @@ -189,7 +190,7 @@ export const SAMPLE_SUBWORKFLOW_WORKFLOW: WorkflowDataWithTemplateId = { }, ] as INodeUi[], connections: { - 'Execute Workflow Trigger': { + 'Workflow Input Trigger': { main: [ [ { diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 638000b219..3af5aa9111 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -58,6 +58,7 @@ "generic.yes": "Yes", "generic.no": "No", "generic.rating": "Rating", + "generic.refresh": "Refresh", "generic.retry": "Retry", "generic.error": "Something went wrong", "generic.settings": "Settings", @@ -1572,7 +1573,7 @@ "resourceMapper.fetchingFields.message": "Fetching {fieldWord}", "resourceMapper.fetchingFields.errorMessage": "Can't get {fieldWord}.", "resourceMapper.fetchingFields.noFieldsFound": "No {fieldWord} found in {serviceName}.", - "resourceMapper.columnsToMatchOn.label": "{fieldWord} to Match On", + "resourceMapper.columnsToMatchOn.label": "{fieldWord} to match on", "resourceMapper.columnsToMatchOn.multi.description": "The {fieldWord} to use when matching rows in {nodeDisplayName} to the input items of this node. Usually an ID.", "resourceMapper.columnsToMatchOn.single.description": "The {fieldWord} to use when matching rows in {nodeDisplayName} to the input items of this node. Usually an ID.", "resourceMapper.columnsToMatchOn.tooltip": "The {fieldWord} to compare when finding the rows to update", @@ -1583,11 +1584,17 @@ "resourceMapper.usingToMatch.description": "This {fieldWord} won't be updated and can't be removed, as it's used for matching", "resourceMapper.removeField": "Remove {fieldWord}", "resourceMapper.mandatoryField.title": "This {fieldWord} is mandatory and can’t be removed", - "resourceMapper.addFieldToSend": "Add {fieldWord} to Send", + "resourceMapper.addFieldToSend": "Add {fieldWord} to send", "resourceMapper.matching.title": "This {fieldWord} is used for matching and can’t be removed", "resourceMapper.addAllFields": "Add All {fieldWord}", "resourceMapper.removeAllFields": "Remove All {fieldWord}", "resourceMapper.refreshFieldList": "Refresh {fieldWord} List", + "resourceMapper.staleDataWarning.tooltip": "{fieldWord} are outdated. Refresh to see the changes.", + "resourceMapper.staleDataWarning.notice": "Refresh to see the updated fields", + "resourceMapper.attemptToConvertTypes.displayName": "Attempt to convert types", + "resourceMapper.attemptToConvertTypes.description": "Attempt to convert types when mapping fields", + "resourceMapper.ignoreTypeMismatchErrors.displayName": "Ignore type mismatch errors", + "resourceMapper.ignoreTypeMismatchErrors.description": "Whether type mismatches should be ignored, rather than returning an Error", "runData.openSubExecution": "Inspect Sub-Execution {id}", "runData.openParentExecution": "Inspect Parent Execution {id}", "runData.emptyItemHint": "This is an item, but it's empty.", diff --git a/packages/editor-ui/src/stores/nodeTypes.store.ts b/packages/editor-ui/src/stores/nodeTypes.store.ts index 7a8e66aab0..06917edfaa 100644 --- a/packages/editor-ui/src/stores/nodeTypes.store.ts +++ b/packages/editor-ui/src/stores/nodeTypes.store.ts @@ -302,6 +302,16 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => { } }; + const getLocalResourceMapperFields = async ( + sendData: DynamicNodeParameters.ResourceMapperFieldsRequest, + ) => { + try { + return await nodeTypesApi.getLocalResourceMapperFields(rootStore.restApiContext, sendData); + } catch (error) { + return null; + } + }; + const getNodeParameterActionResult = async ( sendData: DynamicNodeParameters.ActionResultRequest, ) => { @@ -326,6 +336,7 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => { visibleNodeTypesByInputConnectionTypeNames, isConfigurableNode, getResourceMapperFields, + getLocalResourceMapperFields, getNodeParameterActionResult, getResourceLocatorResults, getNodeParameterOptions, diff --git a/packages/editor-ui/src/utils/nodeTypeUtils.test.ts b/packages/editor-ui/src/utils/nodeTypeUtils.test.ts new file mode 100644 index 0000000000..8eb9c47118 --- /dev/null +++ b/packages/editor-ui/src/utils/nodeTypeUtils.test.ts @@ -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); + }); +}); diff --git a/packages/editor-ui/src/utils/nodeTypesUtils.ts b/packages/editor-ui/src/utils/nodeTypesUtils.ts index 91fe812e4e..3b732dcda6 100644 --- a/packages/editor-ui/src/utils/nodeTypesUtils.ts +++ b/packages/editor-ui/src/utils/nodeTypesUtils.ts @@ -440,6 +440,42 @@ export const fieldCannotBeDeleted = ( ); }; +export const isResourceMapperFieldListStale = ( + oldFields: ResourceMapperField[], + newFields: ResourceMapperField[], +): boolean => { + if (oldFields.length !== newFields.length) { + return true; + } + + // Create map for O(1) lookup + const newFieldsMap = new Map(newFields.map((field) => [field.id, field])); + + // Check if any fields were removed or modified + for (const oldField of oldFields) { + const newField = newFieldsMap.get(oldField.id); + + // Field was removed + if (!newField) { + return true; + } + + // Check if any properties changed + if ( + oldField.displayName !== newField.displayName || + oldField.required !== newField.required || + oldField.defaultMatch !== newField.defaultMatch || + oldField.display !== newField.display || + oldField.canBeUsedToMatch !== newField.canBeUsedToMatch || + oldField.type !== newField.type + ) { + return true; + } + } + + return false; +}; + export const isMatchingField = ( field: string, matchingFields: string[], diff --git a/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow.node.json b/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.json similarity index 100% rename from packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow.node.json rename to packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.json diff --git a/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.test.ts b/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.test.ts new file mode 100644 index 0000000000..f0352c9a31 --- /dev/null +++ b/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.test.ts @@ -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({ + 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', + ); + }); +}); diff --git a/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow.node.ts b/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.ts similarity index 82% rename from packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow.node.ts rename to packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.ts index 4dcc5cae64..a04ef4375c 100644 --- a/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow.node.ts +++ b/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.ts @@ -8,8 +8,11 @@ import type { } from 'n8n-workflow'; import { getWorkflowInfo } from './GenericFunctions'; -import { generatePairedItemData } from '../../utils/utilities'; - +import { generatePairedItemData } from '../../../utils/utilities'; +import { + getCurrentWorkflowInputData, + loadWorkflowInputMappings, +} from '../../../utils/workflowInputsResourceMapping/GenericFunctions'; export class ExecuteWorkflow implements INodeType { description: INodeTypeDescription = { displayName: 'Execute Workflow', @@ -17,7 +20,7 @@ export class ExecuteWorkflow implements INodeType { icon: 'fa:sign-in-alt', iconColor: 'orange-red', group: ['transform'], - version: [1, 1.1], + version: [1, 1.1, 1.2], subtitle: '={{"Workflow: " + $parameter["workflowId"]}}', description: 'Execute another workflow', defaults: { @@ -40,6 +43,13 @@ export class ExecuteWorkflow implements INodeType { }, ], }, + { + displayName: 'This node is out of date. Please upgrade by removing it and adding a new one', + name: 'outdatedVersionWarning', + type: 'notice', + displayOptions: { show: { '@version': [{ _cnd: { lte: 1.1 } }] } }, + default: '', + }, { displayName: 'Source', name: 'source', @@ -68,6 +78,27 @@ export class ExecuteWorkflow implements INodeType { ], default: 'database', description: 'Where to get the workflow to execute from', + displayOptions: { show: { '@version': [{ _cnd: { lte: 1.1 } }] } }, + }, + { + displayName: 'Source', + name: 'source', + type: 'options', + options: [ + { + name: 'Database', + value: 'database', + description: 'Load the workflow from the database by ID', + }, + { + name: 'Define Below', + value: 'parameter', + description: 'Pass the JSON code of a workflow', + }, + ], + default: 'database', + description: 'Where to get the workflow to execute from', + displayOptions: { show: { '@version': [{ _cnd: { gte: 1.2 } }] } }, }, // ---------------------------------- @@ -164,6 +195,43 @@ export class ExecuteWorkflow implements INodeType { name: 'executeWorkflowNotice', type: 'notice', default: '', + displayOptions: { show: { '@version': [{ _cnd: { lte: 1.1 } }] } }, + }, + { + displayName: 'Workflow Inputs', + name: 'workflowInputs', + type: 'resourceMapper', + noDataExpression: true, + default: { + mappingMode: 'defineBelow', + value: null, + }, + required: true, + typeOptions: { + loadOptionsDependsOn: ['workflowId.value'], + resourceMapper: { + localResourceMapperMethod: 'loadWorkflowInputMappings', + valuesLabel: 'Workflow Inputs', + mode: 'map', + fieldWords: { + singular: 'input', + plural: 'inputs', + }, + addAllFields: true, + multiKeyMatch: false, + supportAutoMap: false, + showTypeConversionOptions: true, + }, + }, + displayOptions: { + show: { + source: ['database'], + '@version': [{ _cnd: { gte: 1.2 } }], + }, + hide: { + workflowId: [''], + }, + }, }, { displayName: 'Mode', @@ -206,10 +274,16 @@ export class ExecuteWorkflow implements INodeType { ], }; + methods = { + localResourceMapping: { + loadWorkflowInputMappings, + }, + }; + async execute(this: IExecuteFunctions): Promise { const source = this.getNodeParameter('source', 0) as string; const mode = this.getNodeParameter('mode', 0, false) as string; - const items = this.getInputData(); + const items = getCurrentWorkflowInputData.call(this); const workflowProxy = this.getWorkflowDataProxy(0); const currentWorkflowId = workflowProxy.$workflow.id as string; diff --git a/packages/nodes-base/nodes/ExecuteWorkflow/GenericFunctions.ts b/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/GenericFunctions.ts similarity index 92% rename from packages/nodes-base/nodes/ExecuteWorkflow/GenericFunctions.ts rename to packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/GenericFunctions.ts index 7588040bf8..450a268dfa 100644 --- a/packages/nodes-base/nodes/ExecuteWorkflow/GenericFunctions.ts +++ b/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/GenericFunctions.ts @@ -3,11 +3,16 @@ import { NodeOperationError, jsonParse } from 'n8n-workflow'; import type { IExecuteFunctions, IExecuteWorkflowInfo, + ILoadOptionsFunctions, INodeParameterResourceLocator, IRequestOptions, } from 'n8n-workflow'; -export async function getWorkflowInfo(this: IExecuteFunctions, source: string, itemIndex = 0) { +export async function getWorkflowInfo( + this: ILoadOptionsFunctions | IExecuteFunctions, + source: string, + itemIndex = 0, +) { const workflowInfo: IExecuteWorkflowInfo = {}; const nodeVersion = this.getNode().typeVersion; if (source === 'database') { diff --git a/packages/nodes-base/nodes/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.json b/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.json similarity index 100% rename from packages/nodes-base/nodes/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.json rename to packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.json diff --git a/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.test.ts b/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.test.ts new file mode 100644 index 0000000000..b479538c3a --- /dev/null +++ b/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.test.ts @@ -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({ typeVersion: 1 }); + const executeFns = mock({ + 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); + }); +}); diff --git a/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.ts b/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.ts new file mode 100644 index 0000000000..a15780a80e --- /dev/null +++ b/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.ts @@ -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.
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]; + } + } +} diff --git a/packages/nodes-base/nodes/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.ts b/packages/nodes-base/nodes/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.ts deleted file mode 100644 index feb33a160d..0000000000 --- a/packages/nodes-base/nodes/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.ts +++ /dev/null @@ -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()]; - } -} diff --git a/packages/nodes-base/nodes/ExecuteWorkflowTrigger/test/ExecuteWorkflowTrigger.node.test.ts b/packages/nodes-base/nodes/ExecuteWorkflowTrigger/test/ExecuteWorkflowTrigger.node.test.ts deleted file mode 100644 index ad35bff192..0000000000 --- a/packages/nodes-base/nodes/ExecuteWorkflowTrigger/test/ExecuteWorkflowTrigger.node.test.ts +++ /dev/null @@ -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({ - getInputData: () => mockInputData, - }); - const result = await new ExecuteWorkflowTrigger().execute.call(executeFns); - - expect(result).toEqual([mockInputData]); - }); -}); diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 11585374bf..0307b66721 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -493,8 +493,8 @@ "dist/nodes/ErrorTrigger/ErrorTrigger.node.js", "dist/nodes/Eventbrite/EventbriteTrigger.node.js", "dist/nodes/ExecuteCommand/ExecuteCommand.node.js", - "dist/nodes/ExecuteWorkflow/ExecuteWorkflow.node.js", - "dist/nodes/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.js", + "dist/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.js", + "dist/nodes/ExecuteWorkflow/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.js", "dist/nodes/ExecutionData/ExecutionData.node.js", "dist/nodes/Facebook/FacebookGraphApi.node.js", "dist/nodes/Facebook/FacebookTrigger.node.js", @@ -867,6 +867,7 @@ "fast-glob": "catalog:", "fflate": "0.7.4", "get-system-fonts": "2.0.2", + "generate-schema": "2.6.0", "gm": "1.25.0", "html-to-text": "9.0.5", "iconv-lite": "catalog:", diff --git a/packages/nodes-base/tsconfig.build.json b/packages/nodes-base/tsconfig.build.json index 3a26457c9c..d92417abdd 100644 --- a/packages/nodes-base/tsconfig.build.json +++ b/packages/nodes-base/tsconfig.build.json @@ -8,7 +8,8 @@ "credentials/**/*.ts", "nodes/**/*.ts", "nodes/**/*.json", - "credentials/translations/**/*.json" + "credentials/translations/**/*.json", + "types/**/*.ts" ], "exclude": ["nodes/**/*.test.ts", "credentials/**/*.test.ts", "test/**"] } diff --git a/packages/nodes-base/tsconfig.json b/packages/nodes-base/tsconfig.json index b5a7282ff9..2cbb72109a 100644 --- a/packages/nodes-base/tsconfig.json +++ b/packages/nodes-base/tsconfig.json @@ -10,7 +10,13 @@ "noImplicitReturns": false, "useUnknownInCatchVariables": false }, - "include": ["credentials/**/*.ts", "nodes/**/*.ts", "test/**/*.ts", "utils/**/*.ts"], + "include": [ + "credentials/**/*.ts", + "nodes/**/*.ts", + "test/**/*.ts", + "utils/**/*.ts", + "types/**/*.ts" + ], "references": [ { "path": "../@n8n/imap/tsconfig.build.json" }, { "path": "../workflow/tsconfig.build.json" }, diff --git a/packages/nodes-base/types/generate-schema.d.ts b/packages/nodes-base/types/generate-schema.d.ts new file mode 100644 index 0000000000..90e0e15b05 --- /dev/null +++ b/packages/nodes-base/types/generate-schema.d.ts @@ -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; + required?: string[]; + } + + export interface SchemaProperty { + type: string | string[]; + format?: string; + } + + export function json(title: string, schema: SchemaObject): SchemaObject; + export function json(schema: SchemaObject): SchemaObject; +} diff --git a/packages/nodes-base/utils/workflowInputsResourceMapping/.readme b/packages/nodes-base/utils/workflowInputsResourceMapping/.readme new file mode 100644 index 0000000000..e5556cc0cc --- /dev/null +++ b/packages/nodes-base/utils/workflowInputsResourceMapping/.readme @@ -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 diff --git a/packages/nodes-base/utils/workflowInputsResourceMapping/GenericFunctions.ts b/packages/nodes-base/utils/workflowInputsResourceMapping/GenericFunctions.ts new file mode 100644 index 0000000000..ba1c17f315 --- /dev/null +++ b/packages/nodes-base/utils/workflowInputsResourceMapping/GenericFunctions.ts @@ -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(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 { + 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 }; +} diff --git a/packages/nodes-base/utils/workflowInputsResourceMapping/constants.ts b/packages/nodes-base/utils/workflowInputsResourceMapping/constants.ts new file mode 100644 index 0000000000..409d8d703e --- /dev/null +++ b/packages/nodes-base/utils/workflowInputsResourceMapping/constants.ts @@ -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; diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index c5df420e29..6c28d4664d 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -1017,9 +1017,23 @@ export interface ILoadOptionsFunctions extends FunctionsBase { options?: IGetNodeParameterOptions, ): NodeParameterValueType | object | undefined; getCurrentNodeParameters(): INodeParameters | undefined; + helpers: RequestHelperFunctions & SSHTunnelFunctions; } +export type FieldValueOption = { name: string; type: FieldType | 'any' }; + +export type IWorkflowNodeContext = ExecuteFunctions.GetNodeParameterFn & + Pick; + +export interface ILocalLoadOptionsFunctions { + getWorkflowNodeContext(nodeType: string): Promise; +} + +export interface IWorkflowLoader { + get(workflowId: string): Promise; +} + export interface IPollFunctions extends FunctionsBaseWithRequiredKeys<'getMode' | 'getActivationMode'> { __emit( @@ -1293,14 +1307,18 @@ export interface INodePropertyTypeOptions { resourceMapper?: ResourceMapperTypeOptions; filter?: FilterTypeOptions; assignment?: AssignmentTypeOptions; + minRequiredFields?: number; // Supported by: fixedCollection + maxAllowedFields?: number; // Supported by: fixedCollection [key: string]: any; } -export interface ResourceMapperTypeOptions { - resourceMapperMethod: string; - mode: 'add' | 'update' | 'upsert'; +export interface ResourceMapperTypeOptionsBase { + mode: 'add' | 'update' | 'upsert' | 'map'; valuesLabel?: string; - fieldWords?: { singular: string; plural: string }; + fieldWords?: { + singular: string; + plural: string; + }; addAllFields?: boolean; noFieldsError?: string; multiKeyMatch?: boolean; @@ -1310,8 +1328,23 @@ export interface ResourceMapperTypeOptions { description?: string; hint?: string; }; + showTypeConversionOptions?: boolean; } +// Enforce at least one of resourceMapperMethod or localResourceMapperMethod +export type ResourceMapperTypeOptionsLocal = { + resourceMapperMethod: string; + localResourceMapperMethod?: never; // Explicitly disallows this property +}; + +export type ResourceMapperTypeOptionsExternal = { + localResourceMapperMethod: string; + resourceMapperMethod?: never; // Explicitly disallows this property +}; + +export type ResourceMapperTypeOptions = ResourceMapperTypeOptionsBase & + (ResourceMapperTypeOptionsLocal | ResourceMapperTypeOptionsExternal); + type NonEmptyArray = [T, ...T[]]; export type FilterTypeCombinator = 'and' | 'or'; @@ -1583,6 +1616,9 @@ export interface INodeType { resourceMapping?: { [functionName: string]: (this: ILoadOptionsFunctions) => Promise; }; + localResourceMapping?: { + [functionName: string]: (this: ILocalLoadOptionsFunctions) => Promise; + }; actionHandler?: { [functionName: string]: ( this: ILoadOptionsFunctions, @@ -2651,6 +2687,9 @@ export type ResourceMapperValue = { value: { [key: string]: string | number | boolean | null } | null; matchingColumns: string[]; schema: ResourceMapperField[]; + ignoreTypeMismatchErrors: boolean; + attemptToConvertTypes: boolean; + convertFieldsToString: boolean; }; export type FilterOperatorType = diff --git a/packages/workflow/src/NodeHelpers.ts b/packages/workflow/src/NodeHelpers.ts index 114142bc34..22883a73bb 100644 --- a/packages/workflow/src/NodeHelpers.ts +++ b/packages/workflow/src/NodeHelpers.ts @@ -1568,7 +1568,7 @@ export function getParameterIssues( data: option as INodeProperties, }); } - } else if (nodeProperties.type === 'fixedCollection') { + } else if (nodeProperties.type === 'fixedCollection' && isDisplayed) { basePath = basePath ? `${basePath}.` : `${nodeProperties.name}.`; let propertyOptions: INodePropertyCollection; @@ -1579,6 +1579,24 @@ export function getParameterIssues( propertyOptions.name, basePath.slice(0, -1), ); + + // Validate allowed field counts + const valueArray = Array.isArray(value) ? value : []; + const { minRequiredFields, maxAllowedFields } = nodeProperties.typeOptions ?? {}; + let error = ''; + + if (minRequiredFields && valueArray.length < minRequiredFields) { + error = `At least ${minRequiredFields} ${minRequiredFields === 1 ? 'field is' : 'fields are'} required.`; + } + if (maxAllowedFields && valueArray.length > maxAllowedFields) { + error = `At most ${maxAllowedFields} ${maxAllowedFields === 1 ? 'field is' : 'fields are'} allowed.`; + } + if (error) { + foundIssues.parameters ??= {}; + foundIssues.parameters[nodeProperties.name] ??= []; + foundIssues.parameters[nodeProperties.name].push(error); + } + if (value === undefined) { continue; } diff --git a/packages/workflow/src/WorkflowDataProxy.ts b/packages/workflow/src/WorkflowDataProxy.ts index d94d9220d0..ef314cee18 100644 --- a/packages/workflow/src/WorkflowDataProxy.ts +++ b/packages/workflow/src/WorkflowDataProxy.ts @@ -974,7 +974,12 @@ export class WorkflowDataProxy { type: 'no_execution_data', }); } - return placeholdersDataInputData?.[name] ?? defaultValue; + return ( + // TS does not know that the key exists, we need to address this in refactor + (placeholdersDataInputData?.query as Record)?.[name] ?? + placeholdersDataInputData?.[name] ?? + defaultValue + ); }; const base = { diff --git a/packages/workflow/test/NodeHelpers.test.ts b/packages/workflow/test/NodeHelpers.test.ts index e56a4e20ac..8b3d9b3b02 100644 --- a/packages/workflow/test/NodeHelpers.test.ts +++ b/packages/workflow/test/NodeHelpers.test.ts @@ -1,5 +1,6 @@ import { NodeConnectionType, + type INodeIssues, type INode, type INodeParameters, type INodeProperties, @@ -11,6 +12,7 @@ import { getNodeHints, isSubNodeType, applyDeclarativeNodeOptionParameters, + getParameterIssues, } from '@/NodeHelpers'; import type { Workflow } from '@/Workflow'; @@ -3607,4 +3609,590 @@ describe('NodeHelpers', () => { expect(nodeType.description.properties).toEqual([]); }); }); + + describe('getParameterIssues', () => { + const tests: Array<{ + description: string; + input: { + nodeProperties: INodeProperties; + nodeValues: INodeParameters; + path: string; + node: INode; + }; + output: INodeIssues; + }> = [ + { + description: + 'Fixed collection::Should not return issues if minimum or maximum field count is not set', + input: { + nodeProperties: { + displayName: 'Workflow Inputs', + name: 'workflowInputs', + placeholder: 'Add Field', + type: 'fixedCollection', + description: + 'Define expected input fields. If no inputs are provided, all data from the calling workflow will be passed through.', + typeOptions: { + multipleValues: true, + sortable: true, + }, + displayOptions: { + show: { + '@version': [ + { + _cnd: { + gte: 1.1, + }, + }, + ], + inputSource: ['workflowInputs'], + }, + }, + default: {}, + options: [ + { + name: 'values', + displayName: 'Values', + values: [ + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + placeholder: 'e.g. fieldName', + description: 'Name of the field', + noDataExpression: true, + }, + { + displayName: 'Type', + name: 'type', + type: 'options', + description: 'The field value type', + options: [ + { + name: 'Allow Any Type', + value: 'any', + }, + { + name: 'String', + value: 'string', + }, + { + name: 'Number', + value: 'number', + }, + { + name: 'Boolean', + value: 'boolean', + }, + { + name: 'Array', + value: 'array', + }, + { + name: 'Object', + value: 'object', + }, + ], + default: 'string', + noDataExpression: true, + }, + ], + }, + ], + }, + nodeValues: { + events: 'worklfow_call', + inputSource: 'workflowInputs', + workflowInputs: {}, + inputOptions: {}, + }, + path: '', + node: { + parameters: { + events: 'worklfow_call', + inputSource: 'workflowInputs', + workflowInputs: {}, + inputOptions: {}, + }, + type: 'n8n-nodes-base.executeWorkflowTrigger', + typeVersion: 1.1, + position: [-140, -20], + id: '9abdbdac-5f32-4876-b4d5-895d8ca4cb00', + name: 'Test Node', + } as INode, + }, + output: {}, + }, + { + description: + 'Fixed collection::Should not return issues if field count is within the specified range', + input: { + nodeProperties: { + displayName: 'Workflow Inputs', + name: 'workflowInputs', + placeholder: 'Add Field', + type: 'fixedCollection', + description: + 'Define expected input fields. If no inputs are provided, all data from the calling workflow will be passed through.', + typeOptions: { + multipleValues: true, + sortable: true, + minRequiredFields: 1, + maxAllowedFields: 3, + }, + displayOptions: { + show: { + '@version': [ + { + _cnd: { + gte: 1.1, + }, + }, + ], + inputSource: ['workflowInputs'], + }, + }, + default: {}, + options: [ + { + name: 'values', + displayName: 'Values', + values: [ + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + placeholder: 'e.g. fieldName', + description: 'Name of the field', + noDataExpression: true, + }, + { + displayName: 'Type', + name: 'type', + type: 'options', + description: 'The field value type', + options: [ + { + name: 'Allow Any Type', + value: 'any', + }, + { + name: 'String', + value: 'string', + }, + { + name: 'Number', + value: 'number', + }, + { + name: 'Boolean', + value: 'boolean', + }, + { + name: 'Array', + value: 'array', + }, + { + name: 'Object', + value: 'object', + }, + ], + default: 'string', + noDataExpression: true, + }, + ], + }, + ], + }, + nodeValues: { + events: 'worklfow_call', + inputSource: 'workflowInputs', + workflowInputs: { + values: [ + { + name: 'field1', + type: 'string', + }, + { + name: 'field2', + type: 'string', + }, + ], + }, + inputOptions: {}, + }, + path: '', + node: { + parameters: { + events: 'worklfow_call', + inputSource: 'workflowInputs', + workflowInputs: {}, + inputOptions: {}, + }, + type: 'n8n-nodes-base.executeWorkflowTrigger', + typeVersion: 1.1, + position: [-140, -20], + id: '9abdbdac-5f32-4876-b4d5-895d8ca4cb00', + name: 'Test Node', + } as INode, + }, + output: {}, + }, + { + description: + 'Fixed collection::Should return an issue if field count is lower than minimum specified', + input: { + nodeProperties: { + displayName: 'Workflow Inputs', + name: 'workflowInputs', + placeholder: 'Add Field', + type: 'fixedCollection', + description: + 'Define expected input fields. If no inputs are provided, all data from the calling workflow will be passed through.', + typeOptions: { + multipleValues: true, + sortable: true, + minRequiredFields: 1, + }, + displayOptions: { + show: { + '@version': [ + { + _cnd: { + gte: 1.1, + }, + }, + ], + inputSource: ['workflowInputs'], + }, + }, + default: {}, + options: [ + { + name: 'values', + displayName: 'Values', + values: [ + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + placeholder: 'e.g. fieldName', + description: 'Name of the field', + noDataExpression: true, + }, + { + displayName: 'Type', + name: 'type', + type: 'options', + description: 'The field value type', + options: [ + { + name: 'Allow Any Type', + value: 'any', + }, + { + name: 'String', + value: 'string', + }, + { + name: 'Number', + value: 'number', + }, + { + name: 'Boolean', + value: 'boolean', + }, + { + name: 'Array', + value: 'array', + }, + { + name: 'Object', + value: 'object', + }, + ], + default: 'string', + noDataExpression: true, + }, + ], + }, + ], + }, + nodeValues: { + events: 'worklfow_call', + inputSource: 'workflowInputs', + workflowInputs: {}, + inputOptions: {}, + }, + path: '', + node: { + parameters: { + events: 'worklfow_call', + inputSource: 'workflowInputs', + workflowInputs: {}, + inputOptions: {}, + }, + type: 'n8n-nodes-base.executeWorkflowTrigger', + typeVersion: 1.1, + position: [-140, -20], + id: '9abdbdac-5f32-4876-b4d5-895d8ca4cb00', + name: 'Test Node', + } as INode, + }, + output: { + parameters: { + workflowInputs: ['At least 1 field is required.'], + }, + }, + }, + { + description: + 'Fixed collection::Should return an issue if field count is higher than maximum specified', + input: { + nodeProperties: { + displayName: 'Workflow Inputs', + name: 'workflowInputs', + placeholder: 'Add Field', + type: 'fixedCollection', + description: + 'Define expected input fields. If no inputs are provided, all data from the calling workflow will be passed through.', + typeOptions: { + multipleValues: true, + sortable: true, + maxAllowedFields: 1, + }, + displayOptions: { + show: { + '@version': [ + { + _cnd: { + gte: 1.1, + }, + }, + ], + inputSource: ['workflowInputs'], + }, + }, + default: {}, + options: [ + { + name: 'values', + displayName: 'Values', + values: [ + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + placeholder: 'e.g. fieldName', + description: 'Name of the field', + noDataExpression: true, + }, + { + displayName: 'Type', + name: 'type', + type: 'options', + description: 'The field value type', + options: [ + { + name: 'Allow Any Type', + value: 'any', + }, + { + name: 'String', + value: 'string', + }, + { + name: 'Number', + value: 'number', + }, + { + name: 'Boolean', + value: 'boolean', + }, + { + name: 'Array', + value: 'array', + }, + { + name: 'Object', + value: 'object', + }, + ], + default: 'string', + noDataExpression: true, + }, + ], + }, + ], + }, + nodeValues: { + events: 'worklfow_call', + inputSource: 'workflowInputs', + workflowInputs: { + values: [ + { + name: 'field1', + type: 'string', + }, + { + name: 'field2', + type: 'string', + }, + ], + }, + inputOptions: {}, + }, + path: '', + node: { + parameters: { + events: 'worklfow_call', + inputSource: 'workflowInputs', + workflowInputs: {}, + inputOptions: {}, + }, + type: 'n8n-nodes-base.executeWorkflowTrigger', + typeVersion: 1.1, + position: [-140, -20], + id: '9abdbdac-5f32-4876-b4d5-895d8ca4cb00', + name: 'Test Node', + } as INode, + }, + output: { + parameters: { + workflowInputs: ['At most 1 field is allowed.'], + }, + }, + }, + { + description: 'Fixed collection::Should not return issues if the collection is hidden', + input: { + nodeProperties: { + displayName: 'Workflow Inputs', + name: 'workflowInputs', + placeholder: 'Add Field', + type: 'fixedCollection', + description: + 'Define expected input fields. If no inputs are provided, all data from the calling workflow will be passed through.', + typeOptions: { + multipleValues: true, + sortable: true, + maxAllowedFields: 1, + }, + displayOptions: { + show: { + '@version': [ + { + _cnd: { + gte: 1.1, + }, + }, + ], + inputSource: ['workflowInputs'], + }, + }, + default: {}, + options: [ + { + name: 'values', + displayName: 'Values', + values: [ + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + placeholder: 'e.g. fieldName', + description: 'Name of the field', + noDataExpression: true, + }, + { + displayName: 'Type', + name: 'type', + type: 'options', + description: 'The field value type', + options: [ + { + name: 'Allow Any Type', + value: 'any', + }, + { + name: 'String', + value: 'string', + }, + { + name: 'Number', + value: 'number', + }, + { + name: 'Boolean', + value: 'boolean', + }, + { + name: 'Array', + value: 'array', + }, + { + name: 'Object', + value: 'object', + }, + ], + default: 'string', + noDataExpression: true, + }, + ], + }, + ], + }, + nodeValues: { + events: 'worklfow_call', + inputSource: 'somethingElse', + workflowInputs: { + values: [ + { + name: 'field1', + type: 'string', + }, + { + name: 'field2', + type: 'string', + }, + ], + }, + inputOptions: {}, + }, + path: '', + node: { + parameters: { + events: 'worklfow_call', + inputSource: 'workflowInputs', + workflowInputs: {}, + inputOptions: {}, + }, + type: 'n8n-nodes-base.executeWorkflowTrigger', + typeVersion: 1.1, + position: [-140, -20], + id: '9abdbdac-5f32-4876-b4d5-895d8ca4cb00', + name: 'Test Node', + } as INode, + }, + output: {}, + }, + ]; + + for (const testData of tests) { + test(testData.description, () => { + const result = getParameterIssues( + testData.input.nodeProperties, + testData.input.nodeValues, + testData.input.path, + testData.input.node, + ); + expect(result).toEqual(testData.output); + }); + } + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8eb4e7c8da..a42e557826 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1698,6 +1698,9 @@ importers: fflate: specifier: 0.7.4 version: 0.7.4 + generate-schema: + specifier: 2.6.0 + version: 2.6.0 get-system-fonts: specifier: 2.0.2 version: 2.0.2