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, IParameterLabel,
NodeParameterValueType, NodeParameterValueType,
} from 'n8n-workflow'; } 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 CodeNodeEditor from '@/components/CodeNodeEditor/CodeNodeEditor.vue';
import CredentialsSelect from '@/components/CredentialsSelect.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 TextEdit from '@/components/TextEdit.vue';
import { hasExpressionMapping, isValueExpression } from '@/utils/nodeTypesUtils'; import { hasExpressionMapping, isValueExpression } from '@/utils/nodeTypesUtils';
import { isResourceLocatorValue } from '@/utils/typeGuards';
import { import {
AI_TRANSFORM_NODE_TYPE, AI_TRANSFORM_NODE_TYPE,

View File

@@ -12,9 +12,9 @@ import { useToast } from '@/composables/useToast';
import { useNDVStore } from '@/stores/ndv.store'; import { useNDVStore } from '@/stores/ndv.store';
import { getMappedResult } from '@/utils/mappingUtils'; import { getMappedResult } from '@/utils/mappingUtils';
import { hasExpressionMapping, hasOnlyListMode, isValueExpression } from '@/utils/nodeTypesUtils'; import { hasExpressionMapping, hasOnlyListMode, isValueExpression } from '@/utils/nodeTypesUtils';
import { isResourceLocatorValue } from '@/utils/typeGuards';
import { createEventBus } from '@n8n/utils/event-bus'; import { createEventBus } from '@n8n/utils/event-bus';
import { import {
isResourceLocatorValue,
type INodeProperties, type INodeProperties,
type IParameterLabel, type IParameterLabel,
type NodeParameterValueType, type NodeParameterValueType,

View File

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

View File

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

View File

@@ -59,7 +59,7 @@ const emit = defineEmits<{
'update:modelValue': [elements: CanvasNode[]]; 'update:modelValue': [elements: CanvasNode[]];
'update:node:position': [id: string, position: XYPosition]; 'update:node:position': [id: string, position: XYPosition];
'update:nodes:position': [events: CanvasNodeMoveEvent[]]; 'update:nodes:position': [events: CanvasNodeMoveEvent[]];
'update:node:activated': [id: string]; 'update:node:activated': [id: string, event?: MouseEvent];
'update:node:deactivated': [id: string]; 'update:node:deactivated': [id: string];
'update:node:enabled': [id: string]; 'update:node:enabled': [id: string];
'update:node:selected': [id?: string]; 'update:node:selected': [id?: string];
@@ -95,6 +95,7 @@ const emit = defineEmits<{
'create:workflow': []; 'create:workflow': [];
'drag-and-drop': [position: XYPosition, event: DragEvent]; 'drag-and-drop': [position: XYPosition, event: DragEvent];
'tidy-up': [CanvasLayoutEvent]; 'tidy-up': [CanvasLayoutEvent];
'open:sub-workflow': [nodeId: string];
}>(); }>();
const props = withDefaults( const props = withDefaults(
@@ -266,6 +267,7 @@ function selectUpstreamNodes(id: string) {
const keyMap = computed(() => { const keyMap = computed(() => {
const readOnlyKeymap = { const readOnlyKeymap = {
ctrl_shift_o: emitWithLastSelectedNode((id) => emit('open:sub-workflow', id)),
ctrl_c: emitWithSelectedNodes((ids) => emit('copy:nodes', ids)), ctrl_c: emitWithSelectedNodes((ids) => emit('copy:nodes', ids)),
enter: emitWithLastSelectedNode((id) => onSetNodeActivated(id)), enter: emitWithLastSelectedNode((id) => onSetNodeActivated(id)),
ctrl_a: () => addSelectedNodes(graphNodes.value), 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' }); 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) { 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' }); return props.eventBus.emit('nodes:action', { ids: nodeIds, action: 'update:sticky:color' });
case 'tidy_up': case 'tidy_up':
return await onTidyUp({ source: 'context-menu' }); 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]; run: [id: string];
select: [id: string, selected: boolean]; select: [id: string, selected: boolean];
toggle: [id: string]; toggle: [id: string];
activate: [id: string]; activate: [id: string, event: MouseEvent];
deactivate: [id: string]; deactivate: [id: string];
'open:contextmenu': [id: string, event: MouseEvent, source: 'node-button' | 'node-right-click']; 'open:contextmenu': [id: string, event: MouseEvent, source: 'node-button' | 'node-right-click'];
update: [id: string, parameters: Record<string, unknown>]; update: [id: string, parameters: Record<string, unknown>];
@@ -240,8 +240,8 @@ function onDisabledToggle() {
emit('toggle', props.id); emit('toggle', props.id);
} }
function onActivate() { function onActivate(id: string, event: MouseEvent) {
emit('activate', props.id); emit('activate', id, event);
} }
function onDeactivate() { function onDeactivate() {

View File

@@ -12,7 +12,7 @@ const i18n = useI18n();
const emit = defineEmits<{ const emit = defineEmits<{
'open:contextmenu': [event: MouseEvent]; 'open:contextmenu': [event: MouseEvent];
activate: [id: string]; activate: [id: string, event: MouseEvent];
}>(); }>();
const { initialized, viewport } = useCanvas(); const { initialized, viewport } = useCanvas();
@@ -130,8 +130,8 @@ function openContextMenu(event: MouseEvent) {
emit('open:contextmenu', event); emit('open:contextmenu', event);
} }
function onActivate() { function onActivate(event: MouseEvent) {
emit('activate', id.value); emit('activate', id.value, event);
} }
</script> </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`] = ` 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`] = ` 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}` }); 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 { return {
lastClickPosition, lastClickPosition,
editableWorkflow, editableWorkflow,
@@ -2058,5 +2067,6 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
openExecution, openExecution,
toggleChatOpen, toggleChatOpen,
importTemplate, importTemplate,
tryToOpenSubworkflowInNewTab,
}; };
} }

View File

@@ -6,7 +6,7 @@ import { createPinia, setActivePinia } from 'pinia';
import { useSourceControlStore } from '@/stores/sourceControl.store'; import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.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 => ({ const nodeFactory = (data: Partial<INodeUi> = {}): INodeUi => ({
id: faker.string.uuid(), id: faker.string.uuid(),
@@ -92,6 +92,43 @@ describe('useContextMenu', () => {
expect(targetNodeIds.value).toEqual([sticky.id]); 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"', () => { it('should disable pinning for node that has other inputs then "main"', () => {
const { open, isOpen, actions, targetNodeIds } = useContextMenu(); const { open, isOpen, actions, targetNodeIds } = useContextMenu();
const basicChain = nodeFactory({ type: BASIC_CHAIN_NODE_TYPE }); const basicChain = nodeFactory({ type: BASIC_CHAIN_NODE_TYPE });

View File

@@ -1,5 +1,9 @@
import type { ActionDropdownItem, XYPosition, INodeUi } from '@/Interface'; 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 { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useSourceControlStore } from '@/stores/sourceControl.store'; import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
@@ -33,6 +37,7 @@ export type ContextMenuAction =
| 'add_node' | 'add_node'
| 'add_sticky' | 'add_sticky'
| 'change_color' | 'change_color'
| 'open_sub_workflow'
| 'tidy_up'; | 'tidy_up';
const position = ref<XYPosition>([0, 0]); const position = ref<XYPosition>([0, 0]);
@@ -61,6 +66,15 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
workflowsStore.workflow.isArchived, 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(() => { const targetNodeIds = computed(() => {
if (!isOpen.value || !target.value) return []; if (!isOpen.value || !target.value) return [];
@@ -128,6 +142,7 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
const nodes = targetNodes.value; const nodes = targetNodes.value;
const onlyStickies = nodes.every((node) => node.type === STICKY_NODE_TYPE); const onlyStickies = nodes.every((node) => node.type === STICKY_NODE_TYPE);
const i18nOptions = { const i18nOptions = {
adjustToNumber: nodes.length, adjustToNumber: nodes.length,
interpolate: { interpolate: {
@@ -221,7 +236,8 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
].filter(Boolean) as ActionDropdownItem[]; ].filter(Boolean) as ActionDropdownItem[];
if (nodes.length === 1) { if (nodes.length === 1) {
const singleNodeActions = onlyStickies const isExecuteWorkflowNode = nodes[0].type === EXECUTE_WORKFLOW_NODE_TYPE;
const singleNodeActions: ActionDropdownItem[] = onlyStickies
? [ ? [
{ {
id: 'open', id: 'open',
@@ -253,6 +269,15 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
disabled: isReadOnly.value, 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 // Add actions only available for a single node
menuActions.unshift(...singleNodeActions); menuActions.unshift(...singleNodeActions);
} }

View File

@@ -1516,6 +1516,7 @@
"contextMenu.open": "Open...", "contextMenu.open": "Open...",
"contextMenu.test": "Test step", "contextMenu.test": "Test step",
"contextMenu.rename": "Rename", "contextMenu.rename": "Rename",
"contextMenu.openSubworkflow": "Open Sub-workflow",
"contextMenu.copy": "Copy | Copy {count} {subject}", "contextMenu.copy": "Copy | Copy {count} {subject}",
"contextMenu.deactivate": "Deactivate | Deactivate {count} {subject}", "contextMenu.deactivate": "Deactivate | Deactivate {count} {subject}",
"contextMenu.activate": "Activate | Activate {count} nodes", "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 { isResourceLocatorValue } from 'n8n-workflow';
import type { INodeProperties, NodeParameterValueType } from 'n8n-workflow';
import { isExpression } from './expressions'; import { isExpression } from './expressions';
const validJsIdNameRegex = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; 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 { useCredentialsStore } from '@/stores/credentials.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { isResourceLocatorValue } from '@/utils/typeGuards';
import { isJsonKeyObject } from '@/utils/typesUtils'; import { isJsonKeyObject } from '@/utils/typesUtils';
import type { import {
IDataObject, isResourceLocatorValue,
INodeCredentialDescription, type IDataObject,
INodeExecutionData, type INodeCredentialDescription,
INodeProperties, type INodeExecutionData,
INodeTypeDescription, type INodeProperties,
NodeParameterValueType, type INodeTypeDescription,
ResourceMapperField, type NodeParameterValueType,
Themed, type ResourceMapperField,
type Themed,
} from 'n8n-workflow'; } from 'n8n-workflow';
/* /*

View File

@@ -1,5 +1,4 @@
import type { import type {
INodeParameterResourceLocator,
INodeTypeDescription, INodeTypeDescription,
NodeConnectionType, NodeConnectionType,
TriggerPanelDefinition, TriggerPanelDefinition,
@@ -27,10 +26,6 @@ export const checkExhaustive = (value: never): never => {
throw new Error(`Unhandled value: ${value}`); 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 { export function isNotNull<T>(value: T | null): value is T {
return value !== null; return value !== null;
} }

View File

@@ -213,6 +213,7 @@ const {
setNodeActiveByName, setNodeActiveByName,
clearNodeActive, clearNodeActive,
addConnections, addConnections,
tryToOpenSubworkflowInNewTab,
importWorkflowData, importWorkflowData,
fetchWorkflowDataFromUrl, fetchWorkflowDataFromUrl,
resetWorkspace, resetWorkspace,
@@ -665,10 +666,22 @@ function onClickNode() {
closeNodeCreator(); 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); setNodeActive(id);
} }
function onOpenSubWorkflow(id: string) {
tryToOpenSubworkflowInNewTab(id);
}
function onSetNodeDeactivated() { function onSetNodeDeactivated() {
clearNodeActive(); clearNodeActive();
} }
@@ -1888,6 +1901,7 @@ onBeforeUnmount(() => {
@update:node:parameters="onUpdateNodeParameters" @update:node:parameters="onUpdateNodeParameters"
@update:node:inputs="onUpdateNodeInputs" @update:node:inputs="onUpdateNodeInputs"
@update:node:outputs="onUpdateNodeOutputs" @update:node:outputs="onUpdateNodeOutputs"
@open:sub-workflow="onOpenSubWorkflow"
@click:node="onClickNode" @click:node="onClickNode"
@click:node:add="onClickNodeAdd" @click:node:add="onClickNodeAdd"
@run:node="onRunWorkflowToNode" @run:node="onRunWorkflowToNode"

View File

@@ -6,6 +6,7 @@
import get from 'lodash/get'; import get from 'lodash/get';
import isEqual from 'lodash/isEqual'; import isEqual from 'lodash/isEqual';
import { EXECUTE_WORKFLOW_NODE_TYPE } from './Constants';
import { ApplicationError } from './errors/application.error'; import { ApplicationError } from './errors/application.error';
import { NodeConnectionTypes } from './Interfaces'; import { NodeConnectionTypes } from './Interfaces';
import type { import type {
@@ -39,6 +40,7 @@ import type {
import { validateFilterParameter } from './NodeParameters/FilterParameter'; import { validateFilterParameter } from './NodeParameters/FilterParameter';
import { import {
isFilterValue, isFilterValue,
isResourceLocatorValue,
isResourceMapperValue, isResourceMapperValue,
isValidResourceLocatorParameterValue, isValidResourceLocatorParameterValue,
} from './type-guards'; } from './type-guards';
@@ -1562,3 +1564,17 @@ export function isExecutable(workflow: Workflow, node: INode, nodeTypeData: INod
isTriggerNode(nodeTypeData) 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, ITaskData,
IWorkflowDataProxyAdditionalKeys, IWorkflowDataProxyAdditionalKeys,
IWorkflowDataProxyData, IWorkflowDataProxyData,
INodeParameterResourceLocator,
NodeParameterValueType, NodeParameterValueType,
WorkflowExecuteMode, WorkflowExecuteMode,
ProxyInput, ProxyInput,
INode, INode,
} from './Interfaces'; } from './Interfaces';
import * as NodeHelpers from './NodeHelpers'; import * as NodeHelpers from './NodeHelpers';
import { isResourceLocatorValue } from './type-guards';
import { deepCopy, isObjectEmpty } from './utils'; import { deepCopy, isObjectEmpty } from './utils';
import type { Workflow } from './Workflow'; import type { Workflow } from './Workflow';
import type { EnvProviderState } from './WorkflowDataProxyEnvProvider'; import type { EnvProviderState } from './WorkflowDataProxyEnvProvider';
import { createEnvProvider, createEnvProviderState } from './WorkflowDataProxyEnvProvider'; import { createEnvProvider, createEnvProviderState } from './WorkflowDataProxyEnvProvider';
import { getPinDataIfManualExecution } from './WorkflowDataProxyHelpers'; 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 isScriptingNode = (nodeName: string, workflow: Workflow) => {
const node = workflow.getNode(nodeName); const node = workflow.getNode(nodeName);

View File

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

View File

@@ -7,6 +7,12 @@ import type {
FilterValue, FilterValue,
} from './Interfaces'; } 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 = ( export const isINodeProperties = (
item: INodePropertyOptions | INodeProperties | INodePropertyCollection, item: INodePropertyOptions | INodeProperties | INodePropertyCollection,
): item is INodeProperties => 'name' in item && 'type' in item && !('value' in item); ): item is INodeProperties => 'name' in item && 'type' in item && !('value' in item);