diff --git a/cypress/constants.ts b/cypress/constants.ts index 6f7e7b978d317..cbbf838530133 100644 --- a/cypress/constants.ts +++ b/cypress/constants.ts @@ -61,6 +61,7 @@ export const AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME = 'OpenAI Chat Model' export const AI_MEMORY_POSTGRES_NODE_NAME = 'Postgres Chat Memory'; export const AI_OUTPUT_PARSER_AUTO_FIXING_NODE_NAME = 'Auto-fixing Output Parser'; export const WEBHOOK_NODE_NAME = 'Webhook'; +export const EXECUTE_WORKFLOW_NODE_NAME = 'Execute Workflow'; export const META_KEY = Cypress.platform === 'darwin' ? 'meta' : 'ctrl'; diff --git a/cypress/e2e/45-workflow-selector-parameter.cy.ts b/cypress/e2e/45-workflow-selector-parameter.cy.ts new file mode 100644 index 0000000000000..a6dc23e6c2926 --- /dev/null +++ b/cypress/e2e/45-workflow-selector-parameter.cy.ts @@ -0,0 +1,82 @@ +import { EXECUTE_WORKFLOW_NODE_NAME } from '../constants'; +import { WorkflowPage as WorkflowPageClass, NDV } from '../pages'; +import { getVisiblePopper } from '../utils'; + +const workflowPage = new WorkflowPageClass(); +const ndv = new NDV(); + +describe('Workflow Selector Parameter', () => { + beforeEach(() => { + cy.resetDatabase(); + cy.signinAsOwner(); + ['Get_Weather', 'Search_DB'].forEach((workflowName) => { + workflowPage.actions.visit(); + cy.createFixtureWorkflow(`Test_Subworkflow_${workflowName}.json`, workflowName); + workflowPage.actions.saveWorkflowOnButtonClick(); + }); + workflowPage.actions.visit(); + workflowPage.actions.addInitialNodeToCanvas(EXECUTE_WORKFLOW_NODE_NAME, { + keepNdvOpen: true, + action: 'Call Another Workflow', + }); + }); + it('should render sub-workflows list', () => { + ndv.getters.resourceLocator('workflowId').should('be.visible'); + ndv.getters.resourceLocatorInput('workflowId').click(); + + getVisiblePopper() + .should('have.length', 1) + .findChildByTestId('rlc-item') + .should('have.length', 2); + }); + + it('should show required parameter warning', () => { + ndv.getters.resourceLocator('workflowId').should('be.visible'); + ndv.getters.resourceLocatorInput('workflowId').click(); + ndv.getters.parameterInputIssues('workflowId').should('exist'); + }); + + it('should filter sub-workflows list', () => { + ndv.getters.resourceLocator('workflowId').should('be.visible'); + ndv.getters.resourceLocatorInput('workflowId').click(); + ndv.getters.resourceLocatorSearch('workflowId').type('Weather'); + + getVisiblePopper() + .should('have.length', 1) + .findChildByTestId('rlc-item') + .should('have.length', 1) + .click(); + + ndv.getters + .resourceLocatorInput('workflowId') + .find('input') + .should('have.value', 'Get_Weather'); + }); + + it('should render sub-workflow links correctly', () => { + ndv.getters.resourceLocator('workflowId').should('be.visible'); + ndv.getters.resourceLocatorInput('workflowId').click(); + + getVisiblePopper().findChildByTestId('rlc-item').first().click(); + + ndv.getters.resourceLocatorInput('workflowId').find('a').should('exist'); + cy.getByTestId('radio-button-expression').eq(1).click(); + ndv.getters.resourceLocatorInput('workflowId').find('a').should('not.exist'); + }); + + it('should switch to ID mode on expression', () => { + ndv.getters.resourceLocator('workflowId').should('be.visible'); + ndv.getters.resourceLocatorInput('workflowId').click(); + + getVisiblePopper().findChildByTestId('rlc-item').first().click(); + ndv.getters + .resourceLocatorModeSelector('workflowId') + .find('input') + .should('have.value', 'From list'); + cy.getByTestId('radio-button-expression').eq(1).click(); + ndv.getters + .resourceLocatorModeSelector('workflowId') + .find('input') + .should('have.value', 'By ID'); + }); +}); diff --git a/cypress/fixtures/Test_Subworkflow_Get_Weather.json b/cypress/fixtures/Test_Subworkflow_Get_Weather.json new file mode 100644 index 0000000000000..3829aca879c3a --- /dev/null +++ b/cypress/fixtures/Test_Subworkflow_Get_Weather.json @@ -0,0 +1,53 @@ +{ + "name": "Get Weather", + "nodes": [ + { + "parameters": {}, + "id": "82eed1ba-179b-4f8f-8a85-b45f0d4e5857", + "name": "Execute Workflow Trigger", + "type": "n8n-nodes-base.executeWorkflowTrigger", + "typeVersion": 1, + "position": [ + 560, + 340 + ] + }, + { + "parameters": { + "assignments": { + "assignments": [ + { + "id": "6ad8dc55-20f3-45af-a724-c7ecac90d338", + "name": "response", + "value": "Weather is sunny", + "type": "string" + } + ] + }, + "options": {} + }, + "id": "8f3e00f6-fc92-4aba-817b-93d206158bda", + "name": "Edit Fields", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 780, + 340 + ] + } + ], + "pinData": {}, + "connections": { + "Execute Workflow Trigger": { + "main": [ + [ + { + "node": "Edit Fields", + "type": "main", + "index": 0 + } + ] + ] + } + } +} diff --git a/cypress/fixtures/Test_Subworkflow_Search_DB.json b/cypress/fixtures/Test_Subworkflow_Search_DB.json new file mode 100644 index 0000000000000..990aee120d4de --- /dev/null +++ b/cypress/fixtures/Test_Subworkflow_Search_DB.json @@ -0,0 +1,64 @@ +{ + "name": "Search DB", + "nodes": [ + { + "parameters": {}, + "id": "64465f9b-63de-43f9-8d90-b5b2eb7a2dc7", + "name": "Execute Workflow Trigger", + "type": "n8n-nodes-base.executeWorkflowTrigger", + "typeVersion": 1, + "position": [ + 640, + 380 + ] + }, + { + "parameters": { + "assignments": { + "assignments": [ + { + "id": "6ad8dc55-20f3-45af-a724-c7ecac90d338", + "name": "response", + "value": "10 results found", + "type": "string" + } + ] + }, + "options": {} + }, + "id": "b580fd2b-00c8-4a52-8acb-024f204c0947", + "name": "Edit Fields", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 860, + 380 + ] + } + ], + "pinData": {}, + "connections": { + "Execute Workflow Trigger": { + "main": [ + [ + { + "node": "Edit Fields", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "versionId": "6026f7a4-f5dc-4c27-9f83-3a02fc6e33ae", + "meta": { + "templateCredsSetupCompleted": true, + "instanceId": "27cc9b56542ad45b38725555722c50a1c3fee1670bbb67980558314ee08517c4" + }, + "id": "BFFhCdBZmNSkx4qf", + "tags": [] +} \ No newline at end of file diff --git a/packages/@n8n/nodes-langchain/nodes/retrievers/RetrieverWorkflow/RetrieverWorkflow.node.ts b/packages/@n8n/nodes-langchain/nodes/retrievers/RetrieverWorkflow/RetrieverWorkflow.node.ts index 5b2d852178932..6b446149fca3a 100644 --- a/packages/@n8n/nodes-langchain/nodes/retrievers/RetrieverWorkflow/RetrieverWorkflow.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/retrievers/RetrieverWorkflow/RetrieverWorkflow.node.ts @@ -9,6 +9,7 @@ import type { INodeType, INodeTypeDescription, SupplyData, + INodeParameterResourceLocator, } from 'n8n-workflow'; import { BaseRetriever, type BaseRetrieverInput } from '@langchain/core/retrievers'; @@ -41,7 +42,7 @@ export class RetrieverWorkflow implements INodeType { name: 'retrieverWorkflow', icon: 'fa:box-open', group: ['transform'], - version: 1, + version: [1, 1.1], description: 'Use an n8n Workflow as Retriever', defaults: { name: 'Workflow Retriever', @@ -105,12 +106,26 @@ export class RetrieverWorkflow implements INodeType { displayOptions: { show: { source: ['database'], + '@version': [{ _cnd: { eq: 1 } }], }, }, default: '', required: true, description: 'The workflow to execute', }, + { + displayName: 'Workflow', + name: 'workflowId', + type: 'workflowSelector', + displayOptions: { + show: { + source: ['database'], + '@version': [{ _cnd: { gte: 1.1 } }], + }, + }, + default: '', + required: true, + }, // ---------------------------------- // source:parameter @@ -301,11 +316,21 @@ export class RetrieverWorkflow implements INodeType { const workflowInfo: IExecuteWorkflowInfo = {}; if (source === 'database') { - // Read workflow from database - workflowInfo.id = this.executeFunctions.getNodeParameter( - 'workflowId', - itemIndex, - ) as string; + const nodeVersion = this.executeFunctions.getNode().typeVersion; + if (nodeVersion === 1) { + workflowInfo.id = this.executeFunctions.getNodeParameter( + 'workflowId', + itemIndex, + ) as string; + } else { + const { value } = this.executeFunctions.getNodeParameter( + 'workflowId', + itemIndex, + {}, + ) as INodeParameterResourceLocator; + workflowInfo.id = value as string; + } + baseMetadata.workflowId = workflowInfo.id; } else if (source === 'parameter') { // Read workflow from parameter 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 5ed96cbd60123..0b00e17ac46ee 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts @@ -8,6 +8,7 @@ import type { SupplyData, ExecutionError, IDataObject, + INodeParameterResourceLocator, } from 'n8n-workflow'; import { NodeConnectionType, NodeOperationError, jsonParse } from 'n8n-workflow'; import type { SetField, SetNodeOptions } from 'n8n-nodes-base/dist/nodes/Set/v2/helpers/interfaces'; @@ -32,7 +33,7 @@ export class ToolWorkflow implements INodeType { name: 'toolWorkflow', icon: 'fa:network-wired', group: ['transform'], - version: [1, 1.1], + version: [1, 1.1, 1.2], description: 'Uses another n8n workflow as a tool. Allows packaging any n8n node(s) as a tool.', defaults: { name: 'Call n8n Workflow Tool', @@ -142,6 +143,7 @@ export class ToolWorkflow implements INodeType { displayOptions: { show: { source: ['database'], + '@version': [{ _cnd: { lte: 1.1 } }], }, }, default: '', @@ -150,6 +152,20 @@ export class ToolWorkflow implements INodeType { 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 // ---------------------------------- @@ -368,7 +384,17 @@ export class ToolWorkflow implements INodeType { const workflowInfo: IExecuteWorkflowInfo = {}; if (source === 'database') { // Read workflow from database - workflowInfo.id = this.getNodeParameter('workflowId', itemIndex) as string; + 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; + } } else if (source === 'parameter') { // Read workflow from parameter const workflowJson = this.getNodeParameter('workflowJson', itemIndex) as string; diff --git a/packages/editor-ui/src/components/ParameterInput.vue b/packages/editor-ui/src/components/ParameterInput.vue index 533ad6c49d3cb..b55e7b7c6d3b1 100644 --- a/packages/editor-ui/src/components/ParameterInput.vue +++ b/packages/editor-ui/src/components/ParameterInput.vue @@ -15,7 +15,7 @@
+ (() => { }); const isResourceLocatorParameter = computed(() => { - return props.parameter.type === 'resourceLocator'; + return props.parameter.type === 'resourceLocator' || props.parameter.type === 'workflowSelector'; }); const isSecretParameter = computed(() => { diff --git a/packages/editor-ui/src/components/ParameterInputFull.vue b/packages/editor-ui/src/components/ParameterInputFull.vue index f2107df7db988..d0952be794746 100644 --- a/packages/editor-ui/src/components/ParameterInputFull.vue +++ b/packages/editor-ui/src/components/ParameterInputFull.vue @@ -137,7 +137,9 @@ const node = computed(() => ndvStore.activeNode); const hint = computed(() => i18n.nodeText().hint(props.parameter, props.path)); const isInputTypeString = computed(() => props.parameter.type === 'string'); const isInputTypeNumber = computed(() => props.parameter.type === 'number'); -const isResourceLocator = computed(() => props.parameter.type === 'resourceLocator'); +const isResourceLocator = computed( + () => props.parameter.type === 'resourceLocator' || props.parameter.type === 'workflowSelector', +); const isDropDisabled = computed( () => props.parameter.noDataExpression || diff --git a/packages/editor-ui/src/components/ResourceLocator/ResourceLocator.vue b/packages/editor-ui/src/components/ResourceLocator/ResourceLocator.vue index 29b8a498e6bf3..6eebe37e3423b 100644 --- a/packages/editor-ui/src/components/ResourceLocator/ResourceLocator.vue +++ b/packages/editor-ui/src/components/ResourceLocator/ResourceLocator.vue @@ -828,125 +828,5 @@ export default defineComponent({ diff --git a/packages/editor-ui/src/components/ResourceLocator/resourceLocator.scss b/packages/editor-ui/src/components/ResourceLocator/resourceLocator.scss new file mode 100644 index 0000000000000..d2ec38a981b97 --- /dev/null +++ b/packages/editor-ui/src/components/ResourceLocator/resourceLocator.scss @@ -0,0 +1,121 @@ +$--mode-selector-width: 92px; + +.modeSelector { + --input-background-color: initial; + --input-font-color: initial; + --input-border-color: initial; + flex-basis: $--mode-selector-width; + + input { + border-radius: var(--border-radius-base) 0 0 var(--border-radius-base); + border-right: none; + overflow: hidden; + + &:focus { + border-right: var(--border-base); + } + + &:disabled { + cursor: not-allowed !important; + } + } +} + +.resourceLocator { + display: flex; + flex-wrap: wrap; + position: relative; + + --input-issues-width: 28px; + + .inputContainer { + display: flex; + align-items: center; + width: 100%; + + --input-border-top-left-radius: 0; + --input-border-bottom-left-radius: 0; + + > div { + width: 100%; + } + } + + .background { + position: absolute; + background-color: var(--color-background-input-triple); + top: 0; + bottom: 0; + left: 0; + right: var(--input-issues-width); + border: 1px solid var(--border-color-base); + border-radius: var(--border-radius-base); + } + + &.multipleModes { + .inputContainer { + display: flex; + align-items: center; + flex-basis: calc(100% - $--mode-selector-width); + flex-grow: 1; + + input { + border-radius: 0 var(--border-radius-base) var(--border-radius-base) 0; + } + } + } +} + +.droppable { + --input-border-color: var(--color-secondary-tint-1); + --input-border-style: dashed; +} + +.activeDrop { + --input-border-color: var(--color-success); + --input-background-color: var(--color-success-tint-2); + --input-border-style: solid; + + textarea, + input { + cursor: grabbing !important; + } +} + +.selectInput input { + padding-right: 30px !important; + overflow: hidden; + text-overflow: ellipsis; +} + +.selectIcon { + cursor: pointer; + font-size: 14px; + transition: transform 0.3s; + transform: rotateZ(0); + + &.isReverse { + transform: rotateZ(180deg); + } +} + +.listModeInputContainer * { + cursor: pointer; +} + +.error { + max-width: 170px; + word-break: normal; + text-align: center; +} + +.openResourceLink { + width: 25px !important; + padding-left: var(--spacing-2xs); + padding-top: var(--spacing-4xs); + align-self: flex-start; +} + +.parameter-issues { + width: 25px !important; +} diff --git a/packages/editor-ui/src/components/WorkflowSelectorParameterInput/WorkflowSelectorParameterInput.vue b/packages/editor-ui/src/components/WorkflowSelectorParameterInput/WorkflowSelectorParameterInput.vue new file mode 100644 index 0000000000000..41ba8d1a1ba93 --- /dev/null +++ b/packages/editor-ui/src/components/WorkflowSelectorParameterInput/WorkflowSelectorParameterInput.vue @@ -0,0 +1,315 @@ + + + + + diff --git a/packages/editor-ui/src/components/WorkflowSelectorParameterInput/useWorkflowResourceLocatorDropdown.ts b/packages/editor-ui/src/components/WorkflowSelectorParameterInput/useWorkflowResourceLocatorDropdown.ts new file mode 100644 index 0000000000000..f63b9207a9aa9 --- /dev/null +++ b/packages/editor-ui/src/components/WorkflowSelectorParameterInput/useWorkflowResourceLocatorDropdown.ts @@ -0,0 +1,34 @@ +import type { Ref } from 'vue'; +import { nextTick, ref } from 'vue'; + +export function useWorkflowResourceLocatorDropdown( + isListMode: Ref, + inputRef: Ref, +) { + const isDropdownVisible = ref(false); + const resourceDropdownHiding = ref(false); + + function showDropdown() { + if (!isListMode.value || resourceDropdownHiding.value) { + return; + } + + isDropdownVisible.value = true; + } + + function hideDropdown() { + isDropdownVisible.value = false; + + resourceDropdownHiding.value = true; + void nextTick(() => { + inputRef.value?.blur?.(); + resourceDropdownHiding.value = false; + }); + } + + return { + isDropdownVisible, + showDropdown, + hideDropdown, + }; +} diff --git a/packages/editor-ui/src/components/WorkflowSelectorParameterInput/useWorkflowResourceLocatorModes.ts b/packages/editor-ui/src/components/WorkflowSelectorParameterInput/useWorkflowResourceLocatorModes.ts new file mode 100644 index 0000000000000..1e6cae4db5f23 --- /dev/null +++ b/packages/editor-ui/src/components/WorkflowSelectorParameterInput/useWorkflowResourceLocatorModes.ts @@ -0,0 +1,67 @@ +import type { Ref } from 'vue'; +import { computed } from 'vue'; +import { useI18n } from '@/composables/useI18n'; +import type { + INodeParameterResourceLocator, + INodePropertyMode, + ResourceLocatorModes, +} from 'n8n-workflow'; +import type { Router } from 'vue-router'; +import { useWorkflowResourcesLocator } from './useWorkflowResourcesLocator'; + +export function useWorkflowResourceLocatorModes( + modelValue: Ref, + router: Router, +) { + const i18n = useI18n(); + const { getWorkflowName } = useWorkflowResourcesLocator(router); + + const supportedModes = computed(() => [ + { + name: 'list', + type: 'list', + displayName: i18n.baseText('resourceLocator.mode.list'), + }, + { + type: 'string', + name: 'id', + displayName: i18n.baseText('resourceLocator.mode.id'), + }, + ]); + + const selectedMode = computed(() => modelValue.value?.mode || 'list'); + const isListMode = computed(() => selectedMode.value === 'list'); + + function getUpdatedModePayload(value: ResourceLocatorModes): INodeParameterResourceLocator { + if (typeof modelValue !== 'object') { + return { __rl: true, value: modelValue, mode: value }; + } + + if (value === 'id' && selectedMode.value === 'list' && modelValue.value.value) { + return { __rl: true, mode: value, value: modelValue.value.value }; + } + + return { + __rl: true, + mode: value, + value: modelValue.value.value, + cachedResultName: getWorkflowName(modelValue.value.value?.toString() ?? ''), + }; + } + + function getModeLabel(mode: INodePropertyMode): string | null { + if (mode.name === 'id' || mode.name === 'list') { + return i18n.baseText(`resourceLocator.mode.${mode.name}`); + } + + return mode.displayName; + } + + return { + supportedModes, + selectedMode, + isListMode, + getUpdatedModePayload, + getModeLabel, + }; +} diff --git a/packages/editor-ui/src/components/WorkflowSelectorParameterInput/useWorkflowResourcesLocator.ts b/packages/editor-ui/src/components/WorkflowSelectorParameterInput/useWorkflowResourcesLocator.ts new file mode 100644 index 0000000000000..a34e9018e958b --- /dev/null +++ b/packages/editor-ui/src/components/WorkflowSelectorParameterInput/useWorkflowResourcesLocator.ts @@ -0,0 +1,93 @@ +import { ref, computed } from 'vue'; +import { useWorkflowsStore } from '@/stores/workflows.store'; +import { sortBy } from 'lodash-es'; +import type { Router } from 'vue-router'; +import { VIEWS } from '@/constants'; + +import type { IWorkflowDb } from '@/Interface'; + +export function useWorkflowResourcesLocator(router: Router) { + const workflowsStore = useWorkflowsStore(); + const workflowsResources = ref>([]); + const isLoadingResources = ref(true); + const searchFilter = ref(''); + const PAGE_SIZE = 40; + + const sortedWorkflows = computed(() => + sortBy(workflowsStore.allWorkflows, (workflow) => + new Date(workflow.updatedAt).valueOf(), + ).reverse(), + ); + + const hasMoreWorkflowsToLoad = computed( + () => workflowsStore.allWorkflows.length > workflowsResources.value.length, + ); + + const filteredResources = computed(() => { + if (!searchFilter.value) return workflowsResources.value; + + return workflowsStore.allWorkflows + .filter((resource) => resource.name.toLowerCase().includes(searchFilter.value.toLowerCase())) + .map(workflowDbToResourceMapper); + }); + + async function populateNextWorkflowsPage() { + if (workflowsStore.allWorkflows.length <= 1) { + await workflowsStore.fetchAllWorkflows(); + } + const nextPage = sortedWorkflows.value.slice( + workflowsResources.value.length, + workflowsResources.value.length + PAGE_SIZE, + ); + + workflowsResources.value.push(...nextPage.map(workflowDbToResourceMapper)); + } + + async function setWorkflowsResources() { + isLoadingResources.value = true; + await populateNextWorkflowsPage(); + isLoadingResources.value = false; + } + + function workflowDbToResourceMapper(workflow: IWorkflowDb) { + return { + name: getWorkflowName(workflow.id), + value: workflow.id, + url: getWorkflowUrl(workflow.id), + }; + } + + function getWorkflowUrl(workflowId: string) { + const { href } = router.resolve({ name: VIEWS.WORKFLOW, params: { name: workflowId } }); + return href; + } + + function getWorkflowName(id: string): string { + const workflow = workflowsStore.getWorkflowById(id); + if (workflow) { + // Add the project name if it's not a personal project + if (workflow.homeProject && workflow.homeProject.type !== 'personal') { + return `${workflow.homeProject.name} — ${workflow.name}`; + } + return workflow.name; + } + return id; + } + + function onSearchFilter(filter: string) { + searchFilter.value = filter; + } + + return { + workflowsResources, + isLoadingResources, + hasMoreWorkflowsToLoad, + filteredResources, + searchFilter, + getWorkflowUrl, + onSearchFilter, + getWorkflowName, + populateNextWorkflowsPage, + setWorkflowsResources, + }; +} diff --git a/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow.node.ts b/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow.node.ts index 6ab3c4cbf1424..0dfd4b2edf67a 100644 --- a/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow.node.ts +++ b/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow.node.ts @@ -16,7 +16,7 @@ export class ExecuteWorkflow implements INodeType { icon: 'fa:sign-in-alt', iconColor: 'orange-red', group: ['transform'], - version: 1, + version: [1, 1.1], subtitle: '={{"Workflow: " + $parameter["workflowId"]}}', description: 'Execute another workflow', defaults: { @@ -79,6 +79,7 @@ export class ExecuteWorkflow implements INodeType { displayOptions: { show: { source: ['database'], + '@version': [1], }, }, default: '', @@ -87,7 +88,20 @@ export class ExecuteWorkflow implements INodeType { 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.", + }, // ---------------------------------- // source:localFile // ---------------------------------- diff --git a/packages/nodes-base/nodes/ExecuteWorkflow/GenericFunctions.ts b/packages/nodes-base/nodes/ExecuteWorkflow/GenericFunctions.ts index 9e212eb1940ea..7588040bf8ecd 100644 --- a/packages/nodes-base/nodes/ExecuteWorkflow/GenericFunctions.ts +++ b/packages/nodes-base/nodes/ExecuteWorkflow/GenericFunctions.ts @@ -1,13 +1,27 @@ import { readFile as fsReadFile } from 'fs/promises'; import { NodeOperationError, jsonParse } from 'n8n-workflow'; -import type { IExecuteFunctions, IExecuteWorkflowInfo, IRequestOptions } from 'n8n-workflow'; +import type { + IExecuteFunctions, + IExecuteWorkflowInfo, + INodeParameterResourceLocator, + IRequestOptions, +} from 'n8n-workflow'; export async function getWorkflowInfo(this: IExecuteFunctions, source: string, itemIndex = 0) { const workflowInfo: IExecuteWorkflowInfo = {}; - + const nodeVersion = this.getNode().typeVersion; if (source === 'database') { // Read workflow from database - workflowInfo.id = this.getNodeParameter('workflowId', itemIndex) as string; + if (nodeVersion === 1) { + workflowInfo.id = this.getNodeParameter('workflowId', itemIndex) as string; + } else { + const { value } = this.getNodeParameter( + 'workflowId', + itemIndex, + {}, + ) as INodeParameterResourceLocator; + workflowInfo.id = value as string; + } } else if (source === 'localFile') { // Read workflow from filesystem const workflowPath = this.getNodeParameter('workflowPath', itemIndex) as string; diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index a66dcc5eb4755..cab0c6d0e13ee 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -1204,7 +1204,8 @@ export type NodePropertyTypes = | 'resourceMapper' | 'filter' | 'assignmentCollection' - | 'credentials'; + | 'credentials' + | 'workflowSelector'; export type CodeAutocompleteTypes = 'function' | 'functionItem'; diff --git a/packages/workflow/src/NodeHelpers.ts b/packages/workflow/src/NodeHelpers.ts index 1f27bacee9a39..485ad0d9b16a2 100644 --- a/packages/workflow/src/NodeHelpers.ts +++ b/packages/workflow/src/NodeHelpers.ts @@ -1580,7 +1580,7 @@ export function addToIssuesIfMissing( (nodeProperties.type === 'multiOptions' && Array.isArray(value) && value.length === 0) || (nodeProperties.type === 'dateTime' && value === undefined) || (nodeProperties.type === 'options' && (value === '' || value === undefined)) || - (nodeProperties.type === 'resourceLocator' && + ((nodeProperties.type === 'resourceLocator' || nodeProperties.type === 'workflowSelector') && !isValidResourceLocatorParameterValue(value as INodeParameterResourceLocator)) ) { // Parameter is required but empty @@ -1654,7 +1654,10 @@ export function getParameterIssues( } } - if (nodeProperties.type === 'resourceLocator' && isDisplayed) { + if ( + (nodeProperties.type === 'resourceLocator' || nodeProperties.type === 'workflowSelector') && + isDisplayed + ) { const value = getParameterValueByPath(nodeValues, nodeProperties.name, path); if (isINodeParameterResourceLocator(value)) { const mode = nodeProperties.modes?.find((option) => option.name === value.mode);