diff --git a/cypress/constants.ts b/cypress/constants.ts index 6f7e7b978d..cbbf838530 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 0000000000..a6dc23e6c2 --- /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 0000000000..3829aca879 --- /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 0000000000..990aee120d --- /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 5b2d852178..6b446149fc 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 5ed96cbd60..0b00e17ac4 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 533ad6c49d..b55e7b7c6d 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 f2107df7db..d0952be794 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 29b8a498e6..6eebe37e34 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 0000000000..d2ec38a981 --- /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 0000000000..41ba8d1a1b --- /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 0000000000..f63b9207a9 --- /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 0000000000..1e6cae4db5 --- /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 0000000000..a34e9018e9 --- /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 6ab3c4cbf1..0dfd4b2edf 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 9e212eb194..7588040bf8 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 a66dcc5eb4..cab0c6d0e1 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 1f27bacee9..485ad0d9b1 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);