From de49c23971c217e0b9b383d2449f1ed5ab541f2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milorad=20FIlipovi=C4=87?= Date: Tue, 28 Jan 2025 11:33:23 +0100 Subject: [PATCH] feat(editor): Execute sub-workflow UX and copy updates (no-changelog) (#12834) --- .../ToolWorkflow/v2/ToolWorkflowV2.node.ts | 6 +- .../tools/ToolWorkflow/v2/methods/index.ts | 1 + .../v2/methods/localResourceMapping.ts | 24 ++ .../ToolWorkflow/v2/versionDescription.ts | 2 +- .../local-load-options-context.ts | 2 + .../ResourceMapper.test.constants.ts | 291 ++++++++++++++++++ .../ResourceMapper/ResourceMapper.test.ts | 119 +++++++ .../ResourceMapper/ResourceMapper.vue | 15 +- packages/editor-ui/src/constants.workflows.ts | 4 +- .../src/plugins/i18n/locales/en.json | 10 +- .../ExecuteWorkflow/ExecuteWorkflow.node.json | 2 +- .../ExecuteWorkflow/ExecuteWorkflow.node.ts | 16 +- .../ExecuteWorkflow/methods/index.ts | 1 + .../methods/localResourceMapping.ts | 25 ++ .../ExecuteWorkflowTrigger.node.test.ts | 15 +- .../ExecuteWorkflowTrigger.node.ts | 21 +- .../GenericFunctions.ts | 22 +- packages/workflow/src/Interfaces.ts | 9 +- 18 files changed, 539 insertions(+), 46 deletions(-) create mode 100644 packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/methods/index.ts create mode 100644 packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/methods/localResourceMapping.ts create mode 100644 packages/editor-ui/src/components/ResourceMapper/ResourceMapper.test.constants.ts create mode 100644 packages/editor-ui/src/components/ResourceMapper/ResourceMapper.test.ts create mode 100644 packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/methods/index.ts create mode 100644 packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/methods/localResourceMapping.ts 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 index 22ca31e4da..98ca94cb1f 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.node.ts @@ -1,4 +1,3 @@ -import { loadWorkflowInputMappings } from 'n8n-nodes-base/dist/utils/workflowInputsResourceMapping/GenericFunctions'; import type { INodeTypeBaseDescription, ISupplyDataFunctions, @@ -7,6 +6,7 @@ import type { INodeTypeDescription, } from 'n8n-workflow'; +import { localResourceMapping } from './methods'; import { WorkflowToolService } from './utils/WorkflowToolService'; import { versionDescription } from './versionDescription'; @@ -21,9 +21,7 @@ export class ToolWorkflowV2 implements INodeType { } methods = { - localResourceMapping: { - loadWorkflowInputMappings, - }, + localResourceMapping, }; async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/methods/index.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/methods/index.ts new file mode 100644 index 0000000000..f43c9557ea --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/methods/index.ts @@ -0,0 +1 @@ +export * as localResourceMapping from './localResourceMapping'; diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/methods/localResourceMapping.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/methods/localResourceMapping.ts new file mode 100644 index 0000000000..598b3250ea --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/methods/localResourceMapping.ts @@ -0,0 +1,24 @@ +import { loadWorkflowInputMappings } from 'n8n-nodes-base/dist/utils/workflowInputsResourceMapping/GenericFunctions'; +import type { ILocalLoadOptionsFunctions, ResourceMapperFields } from 'n8n-workflow'; + +export async function loadSubWorkflowInputs( + this: ILocalLoadOptionsFunctions, +): Promise { + const { fields, dataMode, subworkflowInfo } = await loadWorkflowInputMappings.bind(this)(); + let emptyFieldsNotice: string | undefined; + if (fields.length === 0) { + const subworkflowLink = subworkflowInfo?.id + ? `sub-workflow’s trigger` + : 'sub-workflow’s trigger'; + + switch (dataMode) { + case 'passthrough': + emptyFieldsNotice = `This sub-workflow will consume all input data passed to it. Define specific expected input in the ${subworkflowLink}.`; + break; + default: + emptyFieldsNotice = `This sub-workflow will not receive any input when called by your AI node. Define your expected input in the ${subworkflowLink}.`; + break; + } + } + return { fields, emptyFieldsNotice }; +} diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/versionDescription.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/versionDescription.ts index 469a7d6d4c..6d4275b449 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/versionDescription.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/versionDescription.ts @@ -107,7 +107,7 @@ export const versionDescription: INodeTypeDescription = { typeOptions: { loadOptionsDependsOn: ['workflowId.value'], resourceMapper: { - localResourceMapperMethod: 'loadWorkflowInputMappings', + localResourceMapperMethod: 'loadSubWorkflowInputs', valuesLabel: 'Workflow Inputs', mode: 'map', fieldWords: { diff --git a/packages/core/src/execution-engine/node-execution-context/local-load-options-context.ts b/packages/core/src/execution-engine/node-execution-context/local-load-options-context.ts index 39456ff966..dd96ab5f74 100644 --- a/packages/core/src/execution-engine/node-execution-context/local-load-options-context.ts +++ b/packages/core/src/execution-engine/node-execution-context/local-load-options-context.ts @@ -35,6 +35,8 @@ export class LocalLoadOptionsContext implements ILocalLoadOptionsFunctions { if (selectedWorkflowNode) { const selectedSingleNodeWorkflow = new Workflow({ + id: dbWorkflow.id, + name: dbWorkflow.name, nodes: [selectedWorkflowNode], connections: {}, active: false, diff --git a/packages/editor-ui/src/components/ResourceMapper/ResourceMapper.test.constants.ts b/packages/editor-ui/src/components/ResourceMapper/ResourceMapper.test.constants.ts new file mode 100644 index 0000000000..6136da25a4 --- /dev/null +++ b/packages/editor-ui/src/components/ResourceMapper/ResourceMapper.test.constants.ts @@ -0,0 +1,291 @@ +import { + NodeConnectionType, + type INode, + type INodeProperties, + type INodeTypeDescription, +} from 'n8n-workflow'; + +export const WORKFLOW_INPUTS_TEST_PARAMETER_PATH = 'parameters.workflowInputs'; + +export const WORKFLOW_INPUTS_TEST_PARAMETER: INodeProperties = { + 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: false, + }, + }, +}; + +export const WORKFLOW_INPUTS_TEST_NODE: INode = { + parameters: { + operation: 'call_workflow', + source: 'database', + workflowId: { + __rl: true, + value: 'test123', + mode: 'list', + cachedResultName: 'Workflow inputs—test', + }, + workflowInputs: { + _custom: { + type: 'reactive', + stateTypeName: 'Reactive', + value: { + mappingMode: 'defineBelow', + value: {}, + matchingColumns: [], + schema: [ + { + id: 'firstName', + displayName: 'First Name', + required: false, + defaultMatch: false, + display: true, + canBeUsedToMatch: true, + type: 'string', + }, + { + id: 'lastName', + displayName: 'Last Name', + required: false, + defaultMatch: false, + display: true, + canBeUsedToMatch: true, + type: 'string', + }, + ], + attemptToConvertTypes: false, + convertFieldsToString: true, + }, + }, + }, + mode: 'once', + options: {}, + }, + type: 'n8n-nodes-base.executeWorkflow', + typeVersion: 1.2, + position: [220, 0], + id: 'test-123', + name: 'Execute Workflow', +}; + +export const EXECUTE_WORKFLOW_NODE_TYPE_TEST: INodeTypeDescription = { + displayName: 'Execute Sub-workflow', + icon: 'fa:sign-in-alt', + iconColor: 'orange-red', + group: ['transform'], + version: [1, 1.1, 1.2], + subtitle: '={{"Workflow: " + $parameter["workflowId"]}}', + description: 'Execute another workflow', + defaults: { name: 'Execute Workflow', color: '#ff6d5a' }, + inputs: [NodeConnectionType.Main], + outputs: [NodeConnectionType.Main], + properties: [ + { + displayName: 'Operation', + name: 'operation', + type: 'hidden', + noDataExpression: true, + default: 'call_workflow', + options: [{ name: 'Execute a Sub-Workflow', value: 'call_workflow' }], + }, + { + 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', + type: 'options', + options: [ + { + name: 'Database', + value: 'database', + description: 'Load the workflow from the database by ID', + }, + { + name: 'Local File', + value: 'localFile', + description: 'Load the workflow from a locally saved file', + }, + { + name: 'Parameter', + value: 'parameter', + description: 'Load the workflow from a parameter', + }, + { name: 'URL', value: 'url', description: 'Load the workflow from an URL' }, + ], + 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 } }] } }, + }, + { + displayName: 'Workflow ID', + name: 'workflowId', + type: 'string', + displayOptions: { show: { source: ['database'], '@version': [1] } }, + default: '', + required: true, + hint: 'Can be found in the URL of the workflow', + description: + "Note on using an expression here: if this node is set to run once with all items, they will all be sent to the same workflow. That workflow's ID will be calculated by evaluating the expression for the first input item.", + }, + { + displayName: 'Workflow', + name: 'workflowId', + type: 'workflowSelector', + displayOptions: { show: { source: ['database'], '@version': [{ _cnd: { gte: 1.1 } }] } }, + default: '', + required: true, + hint: "Note on using an expression here: if this node is set to run once with all items, they will all be sent to the same workflow. That workflow's ID will be calculated by evaluating the expression for the first input item.", + }, + { + displayName: 'Workflow Path', + name: 'workflowPath', + type: 'string', + displayOptions: { show: { source: ['localFile'] } }, + default: '', + placeholder: '/data/workflow.json', + required: true, + description: 'The path to local JSON workflow file to execute', + }, + { + displayName: 'Workflow JSON', + name: 'workflowJson', + type: 'json', + typeOptions: { rows: 10 }, + displayOptions: { show: { source: ['parameter'] } }, + default: '\n\n\n', + required: true, + description: 'The workflow JSON code to execute', + }, + { + displayName: 'Workflow URL', + name: 'workflowUrl', + type: 'string', + displayOptions: { show: { source: ['url'] } }, + default: '', + placeholder: 'https://example.com/workflow.json', + required: true, + description: 'The URL from which to load the workflow from', + }, + { + displayName: + 'Any data you pass into this node will be output by the Execute Workflow Trigger. More info', + 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', + name: 'mode', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Run once with all items', + value: 'once', + description: 'Pass all items into a single execution of the sub-workflow', + }, + { + name: 'Run once for each item', + value: 'each', + description: 'Call the sub-workflow individually for each item', + }, + ], + default: 'once', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + default: {}, + placeholder: 'Add option', + options: [ + { + displayName: 'Wait For Sub-Workflow Completion', + name: 'waitForSubWorkflow', + type: 'boolean', + default: true, + description: + 'Whether the main workflow should wait for the sub-workflow to complete its execution before proceeding', + }, + ], + }, + ], + codex: { + categories: ['Core Nodes'], + subcategories: { 'Core Nodes': ['Helpers', 'Flow'] }, + alias: ['n8n', 'call', 'sub', 'workflow', 'sub-workflow', 'subworkflow'], + resources: { + primaryDocumentation: [ + { + url: 'https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.executeworkflow/', + }, + ], + }, + }, + name: 'n8n-nodes-base.executeWorkflow', +}; diff --git a/packages/editor-ui/src/components/ResourceMapper/ResourceMapper.test.ts b/packages/editor-ui/src/components/ResourceMapper/ResourceMapper.test.ts new file mode 100644 index 0000000000..1f111eab2c --- /dev/null +++ b/packages/editor-ui/src/components/ResourceMapper/ResourceMapper.test.ts @@ -0,0 +1,119 @@ +import { createComponentRenderer } from '@/__tests__/render'; +import ResourceMapper from './ResourceMapper.vue'; +import { createTestingPinia } from '@pinia/testing'; +import { + WORKFLOW_INPUTS_TEST_PARAMETER, + WORKFLOW_INPUTS_TEST_NODE, + WORKFLOW_INPUTS_TEST_PARAMETER_PATH, + EXECUTE_WORKFLOW_NODE_TYPE_TEST, +} from './ResourceMapper.test.constants'; +import { mockedStore, waitAllPromises } from '@/__tests__/utils'; +import { useNodeTypesStore } from '@/stores/nodeTypes.store'; + +vi.mock('vue-router', async () => { + const actual = await vi.importActual('vue-router'); + const params = {}; + const location = {}; + return { + ...actual, + useRouter: () => ({ + push: vi.fn(), + }), + useRoute: () => ({ + params, + location, + }), + }; +}); + +let nodeTypesStore: ReturnType>; + +const renderComponent = createComponentRenderer(ResourceMapper, { + props: { + inputSize: 'small', + labelSize: 'small', + dependentParametersValues: '-1', + isReadonly: false, + teleported: false, + }, + global: { + stubs: { + ParameterInputFull: { template: '
' }, + }, + }, +}); + +describe('ResourceMapper::Workflow Inputs', () => { + beforeEach(() => { + createTestingPinia(); + nodeTypesStore = mockedStore(useNodeTypesStore); + nodeTypesStore.nodeTypes = { + 'n8n-nodes-base.executeWorkflow': { + 1.2: EXECUTE_WORKFLOW_NODE_TYPE_TEST, + }, + }; + }); + + it('renders', async () => { + expect(() => + renderComponent({ + props: { + parameter: WORKFLOW_INPUTS_TEST_PARAMETER, + node: WORKFLOW_INPUTS_TEST_NODE, + path: WORKFLOW_INPUTS_TEST_PARAMETER_PATH, + }, + }), + ).not.toThrow(); + }); + + it('renders workflow inputs list correctly', async () => { + nodeTypesStore.getLocalResourceMapperFields.mockResolvedValue({ + fields: [ + { + id: 'firstName', + displayName: 'First Name', + type: 'string', + required: false, + defaultMatch: false, + display: true, + }, + { + id: 'lastName', + displayName: 'Last Name', + type: 'string', + required: false, + defaultMatch: false, + display: true, + }, + ], + }); + const { getByTestId, getAllByTestId } = renderComponent({ + props: { + parameter: WORKFLOW_INPUTS_TEST_PARAMETER, + node: WORKFLOW_INPUTS_TEST_NODE, + path: WORKFLOW_INPUTS_TEST_PARAMETER_PATH, + }, + }); + await waitAllPromises(); + expect(getByTestId('mapping-fields-container')).toBeInTheDocument(); + expect(getAllByTestId('field-input')).toHaveLength(2); + }); + + it('renders provided empty fields message', async () => { + nodeTypesStore.getLocalResourceMapperFields.mockResolvedValue({ + fields: [], + emptyFieldsNotice: 'Nothing here', + }); + const { queryByTestId, queryAllByTestId, getByTestId } = renderComponent({ + props: { + parameter: WORKFLOW_INPUTS_TEST_PARAMETER, + node: WORKFLOW_INPUTS_TEST_NODE, + path: WORKFLOW_INPUTS_TEST_PARAMETER_PATH, + }, + }); + await waitAllPromises(); + expect(queryByTestId('mapping-fields-container')).not.toBeInTheDocument(); + expect(queryAllByTestId('field-input')).toHaveLength(0); + expect(getByTestId('empty-fields-notice')).toHaveTextContent('Nothing here'); + }); +}); diff --git a/packages/editor-ui/src/components/ResourceMapper/ResourceMapper.vue b/packages/editor-ui/src/components/ResourceMapper/ResourceMapper.vue index c4ef8d8161..f385842683 100644 --- a/packages/editor-ui/src/components/ResourceMapper/ResourceMapper.vue +++ b/packages/editor-ui/src/components/ResourceMapper/ResourceMapper.vue @@ -28,7 +28,7 @@ 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'; +import { N8nButton, N8nCallout, N8nNotice } from 'n8n-design-system'; type Props = { parameter: INodeProperties; @@ -74,6 +74,7 @@ const state = reactive({ refreshInProgress: false, // Shows inline loader when refreshing fields loadingError: false, hasStaleFields: false, + emptyFieldsNotice: '', }); // Reload fields to map when dependent parameters change @@ -315,7 +316,7 @@ async function fetchFields(): Promise { const { resourceMapperMethod, localResourceMapperMethod } = props.parameter.typeOptions?.resourceMapper ?? {}; - let fetchedFields = null; + let fetchedFields: ResourceMapperFields | null = null; if (typeof resourceMapperMethod === 'string') { const requestParams = createRequestParams( @@ -329,6 +330,9 @@ async function fetchFields(): Promise { fetchedFields = await nodeTypesStore.getLocalResourceMapperFields(requestParams); } + if (fetchedFields?.emptyFieldsNotice) { + state.emptyFieldsNotice = fetchedFields.emptyFieldsNotice; + } return fetchedFields; } @@ -619,6 +623,13 @@ defineExpose({ @add-field="addField" @refresh-field-list="initFetching(true)" /> + + + {{ locale.baseText('resourceMapper.staleDataWarning.notice') }}