diff --git a/packages/frontend/editor-ui/src/components/ParameterInput.vue b/packages/frontend/editor-ui/src/components/ParameterInput.vue index f84ba68134..083002ff85 100644 --- a/packages/frontend/editor-ui/src/components/ParameterInput.vue +++ b/packages/frontend/editor-ui/src/components/ParameterInput.vue @@ -23,7 +23,7 @@ import type { IParameterLabel, NodeParameterValueType, } from 'n8n-workflow'; -import { CREDENTIAL_EMPTY_VALUE, NodeHelpers } from 'n8n-workflow'; +import { CREDENTIAL_EMPTY_VALUE, isResourceLocatorValue, NodeHelpers } from 'n8n-workflow'; import CodeNodeEditor from '@/components/CodeNodeEditor/CodeNodeEditor.vue'; import CredentialsSelect from '@/components/CredentialsSelect.vue'; @@ -38,7 +38,6 @@ import SqlEditor from '@/components/SqlEditor/SqlEditor.vue'; import TextEdit from '@/components/TextEdit.vue'; import { hasExpressionMapping, isValueExpression } from '@/utils/nodeTypesUtils'; -import { isResourceLocatorValue } from '@/utils/typeGuards'; import { AI_TRANSFORM_NODE_TYPE, diff --git a/packages/frontend/editor-ui/src/components/ParameterInputFull.vue b/packages/frontend/editor-ui/src/components/ParameterInputFull.vue index 62cb8b3f26..7644122481 100644 --- a/packages/frontend/editor-ui/src/components/ParameterInputFull.vue +++ b/packages/frontend/editor-ui/src/components/ParameterInputFull.vue @@ -12,9 +12,9 @@ import { useToast } from '@/composables/useToast'; import { useNDVStore } from '@/stores/ndv.store'; import { getMappedResult } from '@/utils/mappingUtils'; import { hasExpressionMapping, hasOnlyListMode, isValueExpression } from '@/utils/nodeTypesUtils'; -import { isResourceLocatorValue } from '@/utils/typeGuards'; import { createEventBus } from '@n8n/utils/event-bus'; import { + isResourceLocatorValue, type INodeProperties, type IParameterLabel, type NodeParameterValueType, diff --git a/packages/frontend/editor-ui/src/components/ParameterOptions.vue b/packages/frontend/editor-ui/src/components/ParameterOptions.vue index d9ed1ff4c2..6dd5e34dd5 100644 --- a/packages/frontend/editor-ui/src/components/ParameterOptions.vue +++ b/packages/frontend/editor-ui/src/components/ParameterOptions.vue @@ -1,7 +1,10 @@ diff --git a/packages/frontend/editor-ui/src/composables/__snapshots__/useContextMenu.test.ts.snap b/packages/frontend/editor-ui/src/composables/__snapshots__/useContextMenu.test.ts.snap index a0f49f740e..0ab19d109e 100644 --- a/packages/frontend/editor-ui/src/composables/__snapshots__/useContextMenu.test.ts.snap +++ b/packages/frontend/editor-ui/src/composables/__snapshots__/useContextMenu.test.ts.snap @@ -191,6 +191,128 @@ exports[`useContextMenu > Read-only mode > should return the correct actions whe ] `; +exports[`useContextMenu > should include "Open Sub-workflow" action when node is "Execute Workflow" with a set workflow 1`] = ` +[ + { + "id": "open", + "label": "Open...", + "shortcut": { + "keys": [ + "↵", + ], + }, + }, + { + "disabled": false, + "id": "execute", + "label": "Test step", + }, + { + "disabled": false, + "id": "rename", + "label": "Rename", + "shortcut": { + "keys": [ + "Space", + ], + }, + }, + { + "disabled": false, + "id": "open_sub_workflow", + "label": "Open Sub-workflow", + "shortcut": { + "keys": [ + "O", + ], + "metaKey": true, + "shiftKey": true, + }, + }, + { + "disabled": false, + "id": "toggle_activation", + "label": "Deactivate", + "shortcut": { + "keys": [ + "D", + ], + }, + }, + { + "disabled": true, + "id": "toggle_pin", + "label": "Pin", + "shortcut": { + "keys": [ + "p", + ], + }, + }, + { + "id": "copy", + "label": "Copy", + "shortcut": { + "keys": [ + "C", + ], + "metaKey": true, + }, + }, + { + "disabled": true, + "id": "duplicate", + "label": "Duplicate", + "shortcut": { + "keys": [ + "D", + ], + "metaKey": true, + }, + }, + { + "divided": true, + "id": "tidy_up", + "label": "Tidy up workflow", + "shortcut": { + "altKey": true, + "keys": [ + "T", + ], + "shiftKey": true, + }, + }, + { + "disabled": false, + "divided": true, + "id": "select_all", + "label": "Select all", + "shortcut": { + "keys": [ + "A", + ], + "metaKey": true, + }, + }, + { + "disabled": false, + "id": "deselect_all", + "label": "Clear selection", + }, + { + "disabled": false, + "divided": true, + "id": "delete", + "label": "Delete", + "shortcut": { + "keys": [ + "Del", + ], + }, + }, +] +`; + exports[`useContextMenu > should return the correct actions opening the menu from the button 1`] = ` [ { @@ -492,6 +614,250 @@ exports[`useContextMenu > should return the correct actions when right clicking ] `; +exports[`useContextMenu > should show "Open Sub-workflow" action (disabled) when node is "Execute Workflow" without a set workflow 1`] = ` +[ + { + "id": "open", + "label": "Open...", + "shortcut": { + "keys": [ + "↵", + ], + }, + }, + { + "disabled": false, + "id": "execute", + "label": "Test step", + }, + { + "disabled": false, + "id": "rename", + "label": "Rename", + "shortcut": { + "keys": [ + "Space", + ], + }, + }, + { + "disabled": true, + "id": "open_sub_workflow", + "label": "Open Sub-workflow", + "shortcut": { + "keys": [ + "O", + ], + "metaKey": true, + "shiftKey": true, + }, + }, + { + "disabled": false, + "id": "toggle_activation", + "label": "Deactivate", + "shortcut": { + "keys": [ + "D", + ], + }, + }, + { + "disabled": true, + "id": "toggle_pin", + "label": "Pin", + "shortcut": { + "keys": [ + "p", + ], + }, + }, + { + "id": "copy", + "label": "Copy", + "shortcut": { + "keys": [ + "C", + ], + "metaKey": true, + }, + }, + { + "disabled": true, + "id": "duplicate", + "label": "Duplicate", + "shortcut": { + "keys": [ + "D", + ], + "metaKey": true, + }, + }, + { + "divided": true, + "id": "tidy_up", + "label": "Tidy up workflow", + "shortcut": { + "altKey": true, + "keys": [ + "T", + ], + "shiftKey": true, + }, + }, + { + "disabled": false, + "divided": true, + "id": "select_all", + "label": "Select all", + "shortcut": { + "keys": [ + "A", + ], + "metaKey": true, + }, + }, + { + "disabled": false, + "id": "deselect_all", + "label": "Clear selection", + }, + { + "disabled": false, + "divided": true, + "id": "delete", + "label": "Delete", + "shortcut": { + "keys": [ + "Del", + ], + }, + }, +] +`; + +exports[`useContextMenu > should show "Open Sub-workflow" action (enabled) when node is "Execute Workflow" with a set workflow 1`] = ` +[ + { + "id": "open", + "label": "Open...", + "shortcut": { + "keys": [ + "↵", + ], + }, + }, + { + "disabled": false, + "id": "execute", + "label": "Test step", + }, + { + "disabled": false, + "id": "rename", + "label": "Rename", + "shortcut": { + "keys": [ + "Space", + ], + }, + }, + { + "disabled": false, + "id": "open_sub_workflow", + "label": "Open Sub-workflow", + "shortcut": { + "keys": [ + "O", + ], + "metaKey": true, + "shiftKey": true, + }, + }, + { + "disabled": false, + "id": "toggle_activation", + "label": "Deactivate", + "shortcut": { + "keys": [ + "D", + ], + }, + }, + { + "disabled": true, + "id": "toggle_pin", + "label": "Pin", + "shortcut": { + "keys": [ + "p", + ], + }, + }, + { + "id": "copy", + "label": "Copy", + "shortcut": { + "keys": [ + "C", + ], + "metaKey": true, + }, + }, + { + "disabled": true, + "id": "duplicate", + "label": "Duplicate", + "shortcut": { + "keys": [ + "D", + ], + "metaKey": true, + }, + }, + { + "divided": true, + "id": "tidy_up", + "label": "Tidy up workflow", + "shortcut": { + "altKey": true, + "keys": [ + "T", + ], + "shiftKey": true, + }, + }, + { + "disabled": false, + "divided": true, + "id": "select_all", + "label": "Select all", + "shortcut": { + "keys": [ + "A", + ], + "metaKey": true, + }, + }, + { + "disabled": false, + "id": "deselect_all", + "label": "Clear selection", + }, + { + "disabled": false, + "divided": true, + "id": "delete", + "label": "Delete", + "shortcut": { + "keys": [ + "Del", + ], + }, + }, +] +`; + exports[`useContextMenu > should support opening and closing (default = right click on canvas) 1`] = ` [ { diff --git a/packages/frontend/editor-ui/src/composables/useCanvasOperations.ts b/packages/frontend/editor-ui/src/composables/useCanvasOperations.ts index e20f93a3c2..aa9131312d 100644 --- a/packages/frontend/editor-ui/src/composables/useCanvasOperations.ts +++ b/packages/frontend/editor-ui/src/composables/useCanvasOperations.ts @@ -2008,6 +2008,15 @@ export function useCanvasOperations({ router }: { router: ReturnType = {}): INodeUi => ({ id: faker.string.uuid(), @@ -92,6 +92,43 @@ describe('useContextMenu', () => { expect(targetNodeIds.value).toEqual([sticky.id]); }); + it('should show "Open Sub-workflow" action (enabled) when node is "Execute Workflow" with a set workflow', () => { + const { open, isOpen, actions, targetNodeIds } = useContextMenu(); + const executeWorkflow = nodeFactory({ + type: EXECUTE_WORKFLOW_NODE_TYPE, + parameters: { + workflowId: { + __rl: true, + value: 'qseYRPbw6joqU7RC', + mode: 'list', + cachedResultName: '', + }, + }, + }); + vi.spyOn(workflowsStore, 'getNodeById').mockReturnValue(executeWorkflow); + open(mockEvent, { source: 'node-right-click', nodeId: executeWorkflow.id }); + + expect(isOpen.value).toBe(true); + expect(actions.value).toMatchSnapshot(); + expect(targetNodeIds.value).toEqual([executeWorkflow.id]); + }); + + it('should show "Open Sub-workflow" action (disabled) when node is "Execute Workflow" without a set workflow', () => { + const { open, isOpen, actions, targetNodeIds } = useContextMenu(); + const executeWorkflow = nodeFactory({ + type: EXECUTE_WORKFLOW_NODE_TYPE, + parameters: { + workflowId: {}, + }, + }); + vi.spyOn(workflowsStore, 'getNodeById').mockReturnValue(executeWorkflow); + open(mockEvent, { source: 'node-right-click', nodeId: executeWorkflow.id }); + + expect(isOpen.value).toBe(true); + expect(actions.value).toMatchSnapshot(); + expect(targetNodeIds.value).toEqual([executeWorkflow.id]); + }); + it('should disable pinning for node that has other inputs then "main"', () => { const { open, isOpen, actions, targetNodeIds } = useContextMenu(); const basicChain = nodeFactory({ type: BASIC_CHAIN_NODE_TYPE }); diff --git a/packages/frontend/editor-ui/src/composables/useContextMenu.ts b/packages/frontend/editor-ui/src/composables/useContextMenu.ts index 850813cb65..5ccfc9a5c9 100644 --- a/packages/frontend/editor-ui/src/composables/useContextMenu.ts +++ b/packages/frontend/editor-ui/src/composables/useContextMenu.ts @@ -1,5 +1,9 @@ import type { ActionDropdownItem, XYPosition, INodeUi } from '@/Interface'; -import { NOT_DUPLICATABLE_NODE_TYPES, STICKY_NODE_TYPE } from '@/constants'; +import { + NOT_DUPLICATABLE_NODE_TYPES, + STICKY_NODE_TYPE, + EXECUTE_WORKFLOW_NODE_TYPE, +} from '@/constants'; import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useSourceControlStore } from '@/stores/sourceControl.store'; import { useUIStore } from '@/stores/ui.store'; @@ -33,6 +37,7 @@ export type ContextMenuAction = | 'add_node' | 'add_sticky' | 'change_color' + | 'open_sub_workflow' | 'tidy_up'; const position = ref([0, 0]); @@ -61,6 +66,15 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) = workflowsStore.workflow.isArchived, ); + const canOpenSubworkflow = computed(() => { + if (targetNodes.value.length !== 1) return false; + + const node = targetNodes.value[0]; + if (node.type !== EXECUTE_WORKFLOW_NODE_TYPE) return false; + + return NodeHelpers.getSubworkflowId(node); + }); + const targetNodeIds = computed(() => { if (!isOpen.value || !target.value) return []; @@ -128,6 +142,7 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) = const nodes = targetNodes.value; const onlyStickies = nodes.every((node) => node.type === STICKY_NODE_TYPE); + const i18nOptions = { adjustToNumber: nodes.length, interpolate: { @@ -221,7 +236,8 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) = ].filter(Boolean) as ActionDropdownItem[]; if (nodes.length === 1) { - const singleNodeActions = onlyStickies + const isExecuteWorkflowNode = nodes[0].type === EXECUTE_WORKFLOW_NODE_TYPE; + const singleNodeActions: ActionDropdownItem[] = onlyStickies ? [ { id: 'open', @@ -253,6 +269,15 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) = disabled: isReadOnly.value, }, ]; + + if (isExecuteWorkflowNode) { + singleNodeActions.push({ + id: 'open_sub_workflow', + label: i18n.baseText('contextMenu.openSubworkflow'), + shortcut: { shiftKey: true, metaKey: true, keys: ['O'] }, + disabled: !canOpenSubworkflow.value, + }); + } // Add actions only available for a single node menuActions.unshift(...singleNodeActions); } diff --git a/packages/frontend/editor-ui/src/plugins/i18n/locales/en.json b/packages/frontend/editor-ui/src/plugins/i18n/locales/en.json index dfa4cffef8..37aeccf184 100644 --- a/packages/frontend/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/frontend/editor-ui/src/plugins/i18n/locales/en.json @@ -1516,6 +1516,7 @@ "contextMenu.open": "Open...", "contextMenu.test": "Test step", "contextMenu.rename": "Rename", + "contextMenu.openSubworkflow": "Open Sub-workflow", "contextMenu.copy": "Copy | Copy {count} {subject}", "contextMenu.deactivate": "Deactivate | Deactivate {count} {subject}", "contextMenu.activate": "Activate | Activate {count} nodes", diff --git a/packages/frontend/editor-ui/src/utils/mappingUtils.ts b/packages/frontend/editor-ui/src/utils/mappingUtils.ts index 9587483d73..63721d4997 100644 --- a/packages/frontend/editor-ui/src/utils/mappingUtils.ts +++ b/packages/frontend/editor-ui/src/utils/mappingUtils.ts @@ -1,5 +1,5 @@ -import type { INodeProperties, NodeParameterValueType } from 'n8n-workflow'; import { isResourceLocatorValue } from 'n8n-workflow'; +import type { INodeProperties, NodeParameterValueType } from 'n8n-workflow'; import { isExpression } from './expressions'; const validJsIdNameRegex = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; diff --git a/packages/frontend/editor-ui/src/utils/nodeTypesUtils.ts b/packages/frontend/editor-ui/src/utils/nodeTypesUtils.ts index 8ced5b0242..1ea433540a 100644 --- a/packages/frontend/editor-ui/src/utils/nodeTypesUtils.ts +++ b/packages/frontend/editor-ui/src/utils/nodeTypesUtils.ts @@ -16,17 +16,17 @@ import { i18n as locale } from '@/plugins/i18n'; import { useCredentialsStore } from '@/stores/credentials.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useWorkflowsStore } from '@/stores/workflows.store'; -import { isResourceLocatorValue } from '@/utils/typeGuards'; import { isJsonKeyObject } from '@/utils/typesUtils'; -import type { - IDataObject, - INodeCredentialDescription, - INodeExecutionData, - INodeProperties, - INodeTypeDescription, - NodeParameterValueType, - ResourceMapperField, - Themed, +import { + isResourceLocatorValue, + type IDataObject, + type INodeCredentialDescription, + type INodeExecutionData, + type INodeProperties, + type INodeTypeDescription, + type NodeParameterValueType, + type ResourceMapperField, + type Themed, } from 'n8n-workflow'; /* diff --git a/packages/frontend/editor-ui/src/utils/typeGuards.ts b/packages/frontend/editor-ui/src/utils/typeGuards.ts index 4e050b2f87..7856ce4d57 100644 --- a/packages/frontend/editor-ui/src/utils/typeGuards.ts +++ b/packages/frontend/editor-ui/src/utils/typeGuards.ts @@ -1,5 +1,4 @@ import type { - INodeParameterResourceLocator, INodeTypeDescription, NodeConnectionType, TriggerPanelDefinition, @@ -27,10 +26,6 @@ export const checkExhaustive = (value: never): never => { throw new Error(`Unhandled value: ${value}`); }; -export function isResourceLocatorValue(value: unknown): value is INodeParameterResourceLocator { - return Boolean(typeof value === 'object' && value && 'mode' in value && 'value' in value); -} - export function isNotNull(value: T | null): value is T { return value !== null; } diff --git a/packages/frontend/editor-ui/src/views/NodeView.vue b/packages/frontend/editor-ui/src/views/NodeView.vue index e8a2c5c1fd..e87a4ed02f 100644 --- a/packages/frontend/editor-ui/src/views/NodeView.vue +++ b/packages/frontend/editor-ui/src/views/NodeView.vue @@ -213,6 +213,7 @@ const { setNodeActiveByName, clearNodeActive, addConnections, + tryToOpenSubworkflowInNewTab, importWorkflowData, fetchWorkflowDataFromUrl, resetWorkspace, @@ -665,10 +666,22 @@ function onClickNode() { closeNodeCreator(); } -function onSetNodeActivated(id: string) { +function onSetNodeActivated(id: string, event?: MouseEvent) { + // Handle Ctrl/Cmd + Double Click case + if (event?.metaKey || event?.ctrlKey) { + const didOpen = tryToOpenSubworkflowInNewTab(id); + if (didOpen) { + return; + } + } + setNodeActive(id); } +function onOpenSubWorkflow(id: string) { + tryToOpenSubworkflowInNewTab(id); +} + function onSetNodeDeactivated() { clearNodeActive(); } @@ -1888,6 +1901,7 @@ onBeforeUnmount(() => { @update:node:parameters="onUpdateNodeParameters" @update:node:inputs="onUpdateNodeInputs" @update:node:outputs="onUpdateNodeOutputs" + @open:sub-workflow="onOpenSubWorkflow" @click:node="onClickNode" @click:node:add="onClickNodeAdd" @run:node="onRunWorkflowToNode" diff --git a/packages/workflow/src/NodeHelpers.ts b/packages/workflow/src/NodeHelpers.ts index 4b3f7e7858..8b640313e1 100644 --- a/packages/workflow/src/NodeHelpers.ts +++ b/packages/workflow/src/NodeHelpers.ts @@ -6,6 +6,7 @@ import get from 'lodash/get'; import isEqual from 'lodash/isEqual'; +import { EXECUTE_WORKFLOW_NODE_TYPE } from './Constants'; import { ApplicationError } from './errors/application.error'; import { NodeConnectionTypes } from './Interfaces'; import type { @@ -39,6 +40,7 @@ import type { import { validateFilterParameter } from './NodeParameters/FilterParameter'; import { isFilterValue, + isResourceLocatorValue, isResourceMapperValue, isValidResourceLocatorParameterValue, } from './type-guards'; @@ -1562,3 +1564,17 @@ export function isExecutable(workflow: Workflow, node: INode, nodeTypeData: INod isTriggerNode(nodeTypeData) ); } + +/** + * Attempts to retrieve the ID of a subworkflow from a execute workflow node. + */ +export function getSubworkflowId(node: INode): string | undefined { + if ( + node && + node.type === EXECUTE_WORKFLOW_NODE_TYPE && + isResourceLocatorValue(node.parameters.workflowId) + ) { + return node.parameters.workflowId.value as string; + } + return; +} diff --git a/packages/workflow/src/WorkflowDataProxy.ts b/packages/workflow/src/WorkflowDataProxy.ts index dd25093246..5710f03fbe 100644 --- a/packages/workflow/src/WorkflowDataProxy.ts +++ b/packages/workflow/src/WorkflowDataProxy.ts @@ -22,25 +22,19 @@ import type { ITaskData, IWorkflowDataProxyAdditionalKeys, IWorkflowDataProxyData, - INodeParameterResourceLocator, NodeParameterValueType, WorkflowExecuteMode, ProxyInput, INode, } from './Interfaces'; import * as NodeHelpers from './NodeHelpers'; +import { isResourceLocatorValue } from './type-guards'; import { deepCopy, isObjectEmpty } from './utils'; import type { Workflow } from './Workflow'; import type { EnvProviderState } from './WorkflowDataProxyEnvProvider'; import { createEnvProvider, createEnvProviderState } from './WorkflowDataProxyEnvProvider'; import { getPinDataIfManualExecution } from './WorkflowDataProxyHelpers'; -export function isResourceLocatorValue(value: unknown): value is INodeParameterResourceLocator { - return Boolean( - typeof value === 'object' && value && 'mode' in value && 'value' in value && '__rl' in value, - ); -} - const isScriptingNode = (nodeName: string, workflow: Workflow) => { const node = workflow.getNode(nodeName); diff --git a/packages/workflow/src/index.ts b/packages/workflow/src/index.ts index 7e01f7200f..4e13469fa9 100644 --- a/packages/workflow/src/index.ts +++ b/packages/workflow/src/index.ts @@ -45,6 +45,7 @@ export { isINodePropertyCollectionList, isINodePropertyOptionsList, isResourceMapperValue, + isResourceLocatorValue, isFilterValue, } from './type-guards'; diff --git a/packages/workflow/src/type-guards.ts b/packages/workflow/src/type-guards.ts index 2e3bc7fb3d..bce8370f73 100644 --- a/packages/workflow/src/type-guards.ts +++ b/packages/workflow/src/type-guards.ts @@ -7,6 +7,12 @@ import type { FilterValue, } from './Interfaces'; +export function isResourceLocatorValue(value: unknown): value is INodeParameterResourceLocator { + return Boolean( + typeof value === 'object' && value && 'mode' in value && 'value' in value && '__rl' in value, + ); +} + export const isINodeProperties = ( item: INodePropertyOptions | INodeProperties | INodePropertyCollection, ): item is INodeProperties => 'name' in item && 'type' in item && !('value' in item);