feat(editor): Allow jumping into sub-workflow with shortkey (#15200)

This commit is contained in:
Ricardo Espinoza
2025-05-12 07:24:36 -04:00
committed by GitHub
parent 8b467e3f56
commit e2b9ada4b5
20 changed files with 523 additions and 51 deletions

View File

@@ -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,

View File

@@ -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,

View File

@@ -1,7 +1,10 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
import type { INodeProperties, NodeParameterValueType } from 'n8n-workflow';
import { isResourceLocatorValue } from '@/utils/typeGuards';
import {
isResourceLocatorValue,
type INodeProperties,
type NodeParameterValueType,
} from 'n8n-workflow';
import { isValueExpression } from '@/utils/nodeTypesUtils';
import { computed } from 'vue';
import { useNDVStore } from '@/stores/ndv.store';

View File

@@ -18,19 +18,19 @@ import {
getMainAuthField,
hasOnlyListMode as hasOnlyListModeUtil,
} from '@/utils/nodeTypesUtils';
import { isResourceLocatorValue } from '@/utils/typeGuards';
import stringify from 'fast-json-stable-stringify';
import type { EventBus } from '@n8n/utils/event-bus';
import { createEventBus } from '@n8n/utils/event-bus';
import type {
INode,
INodeListSearchItems,
INodeParameterResourceLocator,
INodeParameters,
INodeProperties,
INodePropertyMode,
INodePropertyModeTypeOptions,
NodeParameterValue,
import {
isResourceLocatorValue,
type INode,
type INodeListSearchItems,
type INodeParameterResourceLocator,
type INodeParameters,
type INodeProperties,
type INodePropertyMode,
type INodePropertyModeTypeOptions,
type NodeParameterValue,
} from 'n8n-workflow';
import {
computed,

View File

@@ -59,7 +59,7 @@ const emit = defineEmits<{
'update:modelValue': [elements: CanvasNode[]];
'update:node:position': [id: string, position: XYPosition];
'update:nodes:position': [events: CanvasNodeMoveEvent[]];
'update:node:activated': [id: string];
'update:node:activated': [id: string, event?: MouseEvent];
'update:node:deactivated': [id: string];
'update:node:enabled': [id: string];
'update:node:selected': [id?: string];
@@ -95,6 +95,7 @@ const emit = defineEmits<{
'create:workflow': [];
'drag-and-drop': [position: XYPosition, event: DragEvent];
'tidy-up': [CanvasLayoutEvent];
'open:sub-workflow': [nodeId: string];
}>();
const props = withDefaults(
@@ -266,6 +267,7 @@ function selectUpstreamNodes(id: string) {
const keyMap = computed(() => {
const readOnlyKeymap = {
ctrl_shift_o: emitWithLastSelectedNode((id) => emit('open:sub-workflow', id)),
ctrl_c: emitWithSelectedNodes((ids) => emit('copy:nodes', ids)),
enter: emitWithLastSelectedNode((id) => onSetNodeActivated(id)),
ctrl_a: () => addSelectedNodes(graphNodes.value),
@@ -368,9 +370,9 @@ function onSelectionEnd() {
}
}
function onSetNodeActivated(id: string) {
function onSetNodeActivated(id: string, event?: MouseEvent) {
props.eventBus.emit('nodes:action', { ids: [id], action: 'update:node:activated' });
emit('update:node:activated', id);
emit('update:node:activated', id, event);
}
function onSetNodeDeactivated(id: string) {
@@ -661,6 +663,9 @@ async function onContextMenuAction(action: ContextMenuAction, nodeIds: string[])
return props.eventBus.emit('nodes:action', { ids: nodeIds, action: 'update:sticky:color' });
case 'tidy_up':
return await onTidyUp({ source: 'context-menu' });
case 'open_sub_workflow': {
return emit('open:sub-workflow', nodeIds[0]);
}
}
}

View File

@@ -56,7 +56,7 @@ const emit = defineEmits<{
run: [id: string];
select: [id: string, selected: boolean];
toggle: [id: string];
activate: [id: string];
activate: [id: string, event: MouseEvent];
deactivate: [id: string];
'open:contextmenu': [id: string, event: MouseEvent, source: 'node-button' | 'node-right-click'];
update: [id: string, parameters: Record<string, unknown>];
@@ -240,8 +240,8 @@ function onDisabledToggle() {
emit('toggle', props.id);
}
function onActivate() {
emit('activate', props.id);
function onActivate(id: string, event: MouseEvent) {
emit('activate', id, event);
}
function onDeactivate() {

View File

@@ -12,7 +12,7 @@ const i18n = useI18n();
const emit = defineEmits<{
'open:contextmenu': [event: MouseEvent];
activate: [id: string];
activate: [id: string, event: MouseEvent];
}>();
const { initialized, viewport } = useCanvas();
@@ -130,8 +130,8 @@ function openContextMenu(event: MouseEvent) {
emit('open:contextmenu', event);
}
function onActivate() {
emit('activate', id.value);
function onActivate(event: MouseEvent) {
emit('activate', id.value, event);
}
</script>

View File

@@ -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`] = `
[
{

View File

@@ -2008,6 +2008,15 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
workflowsStore.addToWorkflowMetadata({ templateId: `${id}` });
}
function tryToOpenSubworkflowInNewTab(nodeId: string): boolean {
const node = workflowsStore.getNodeById(nodeId);
if (!node) return false;
const subWorkflowId = NodeHelpers.getSubworkflowId(node);
if (!subWorkflowId) return false;
window.open(`${rootStore.baseUrl}workflow/${subWorkflowId}`, '_blank');
return true;
}
return {
lastClickPosition,
editableWorkflow,
@@ -2058,5 +2067,6 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
openExecution,
toggleChatOpen,
importTemplate,
tryToOpenSubworkflowInNewTab,
};
}

View File

@@ -6,7 +6,7 @@ import { createPinia, setActivePinia } from 'pinia';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { NodeConnectionTypes, NodeHelpers } from 'n8n-workflow';
import { EXECUTE_WORKFLOW_NODE_TYPE, NodeConnectionTypes, NodeHelpers } from 'n8n-workflow';
const nodeFactory = (data: Partial<INodeUi> = {}): 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 });

View File

@@ -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<XYPosition>([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);
}

View File

@@ -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",

View File

@@ -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_$]*$/;

View File

@@ -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';
/*

View File

@@ -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<T>(value: T | null): value is T {
return value !== null;
}

View File

@@ -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"

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -45,6 +45,7 @@ export {
isINodePropertyCollectionList,
isINodePropertyOptionsList,
isResourceMapperValue,
isResourceLocatorValue,
isFilterValue,
} from './type-guards';

View File

@@ -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);