feat(editor): Show actions in focus panel when multiple nodes are selected (no-changelog) (#18984)

This commit is contained in:
Suguru Inoue
2025-09-03 13:40:00 +02:00
committed by GitHub
parent d8eb1a97e6
commit 95952902f0
18 changed files with 432 additions and 305 deletions

View File

@@ -30,7 +30,9 @@ const template: StoryFn = (args, { argTypes }) => ({
setup: () => ({ args }),
props: Object.keys(argTypes),
components: {
N8nActionDropdown,
// "as unknown ..." is a workaround for generic components.
// See https://github.com/storybookjs/storybook/issues/24238
N8nActionDropdown: N8nActionDropdown as unknown as Record<string, unknown>,
},
template: '<n8n-action-dropdown v-bind="args" />',
});

View File

@@ -1,4 +1,4 @@
<script lang="ts" setup>
<script lang="ts" setup generic="T extends string">
// This component is visually similar to the ActionToggle component
// but it offers more options when it comes to dropdown items styling
// (supports icons, separators, custom styling and all options provided
@@ -19,7 +19,7 @@ import { N8nKeyboardShortcut } from '../N8nKeyboardShortcut';
const TRIGGER = ['click', 'hover'] as const;
interface ActionDropdownProps {
items: ActionDropdownItem[];
items: Array<ActionDropdownItem<T>>;
placement?: Placement;
activatorIcon?: IconName;
activatorSize?: ButtonSize;
@@ -46,7 +46,7 @@ const attrs = useAttrs();
const testIdPrefix = attrs['data-test-id'];
const $style = useCssModule();
const getItemClasses = (item: ActionDropdownItem): Record<string, boolean> => {
const getItemClasses = (item: ActionDropdownItem<T>): Record<string, boolean> => {
return {
[$style.itemContainer]: true,
[$style.disabled]: !!item.disabled,
@@ -56,13 +56,13 @@ const getItemClasses = (item: ActionDropdownItem): Record<string, boolean> => {
};
const emit = defineEmits<{
select: [action: string];
select: [action: T];
visibleChange: [open: boolean];
}>();
defineSlots<{
activator: {};
menuItem: (props: ActionDropdownItem) => void;
menuItem: (props: ActionDropdownItem<T>) => void;
}>();
const elementDropdown = ref<InstanceType<typeof ElDropdown>>();
@@ -72,7 +72,7 @@ const popperClass = computed(
`${$style.shadow}${props.hideArrow ? ` ${$style.hideArrow}` : ''} ${props.extraPopperClass ?? ''}`,
);
const onSelect = (action: string) => emit('select', action);
const onSelect = (action: T) => emit('select', action);
const onVisibleChange = (open: boolean) => emit('visibleChange', open);
const onButtonBlur = (event: FocusEvent) => {

View File

@@ -2,8 +2,8 @@ import type { KeyboardShortcut } from '@n8n/design-system/types/keyboardshortcut
import type { IconName } from '../components/N8nIcon/icons';
export interface ActionDropdownItem {
id: string;
export interface ActionDropdownItem<T extends string> {
id: T;
label: string;
badge?: string;
badgeProps?: Record<string, unknown>;

View File

@@ -1,12 +1,14 @@
<script lang="ts" setup>
import { type ContextMenuAction, useContextMenu } from '@/composables/useContextMenu';
import { useContextMenu } from '@/composables/useContextMenu';
import { type ContextMenuAction } from '@/composables/useContextMenuItems';
import { useStyles } from '@/composables/useStyles';
import { N8nActionDropdown } from '@n8n/design-system';
import { watch, ref } from 'vue';
import { ref, watch } from 'vue';
import { type ComponentExposed } from 'vue-component-type-helpers';
const contextMenu = useContextMenu();
const { position, isOpen, actions, target } = contextMenu;
const dropdown = ref<InstanceType<typeof N8nActionDropdown>>();
const dropdown = ref<ComponentExposed<typeof N8nActionDropdown>>();
const emit = defineEmits<{ action: [action: ContextMenuAction, nodeIds: string[]] }>();
const { APP_Z_INDEXES } = useStyles();
@@ -22,10 +24,8 @@ watch(
{ flush: 'post' },
);
function onActionSelect(item: string) {
const action = item as ContextMenuAction;
contextMenu._dispatchAction(action);
emit('action', action, contextMenu.targetNodeIds.value);
function onActionSelect(item: ContextMenuAction) {
emit('action', item, contextMenu.targetNodeIds.value);
}
function onVisibleChange(open: boolean) {

View File

@@ -37,6 +37,7 @@ import { useNDVStore } from '@/stores/ndv.store';
import { useVueFlow } from '@vue-flow/core';
import ExperimentalFocusPanelHeader from '@/components/canvas/experimental/components/ExperimentalFocusPanelHeader.vue';
import { useTelemetryContext } from '@/composables/useTelemetryContext';
import { type ContextMenuAction } from '@/composables/useContextMenuItems';
defineOptions({ name: 'FocusPanel' });
@@ -47,6 +48,7 @@ const props = defineProps<{
const emit = defineEmits<{
focus: [];
saveKeyboardShortcut: [event: KeyboardEvent];
contextMenuAction: [action: ContextMenuAction, nodeIds: string[]];
}>();
// ESLint: false positive
@@ -205,6 +207,8 @@ const targetNodeParameterContext = computed<TargetNodeParameterContext | undefin
const isNodeExecuting = computed(() => workflowsStore.isNodeExecuting(node.value?.name ?? ''));
const selectedNodeIds = computed(() => vueFlow.getSelectedNodes.value.map((n) => n.id));
const { resolvedExpression } = useResolvedExpression({
expression,
additionalData: resolvedAdditionalExpressionData,
@@ -571,8 +575,9 @@ function onOpenNdv() {
<ExperimentalNodeDetailsDrawer
v-else-if="node && experimentalNdvStore.isNdvInFocusPanelEnabled"
:node="node"
:nodes="vueFlow.getSelectedNodes.value"
:node-ids="selectedNodeIds"
@open-ndv="onOpenNdv"
@context-menu-action="(action, nodeIds) => emit('contextMenuAction', action, nodeIds)"
/>
<div v-else :class="[$style.content, $style.emptyContent]">
<div :class="$style.emptyText">

View File

@@ -147,8 +147,8 @@ const onExecutionsTab = computed(() => {
const workflowPermissions = computed(() => getResourcePermissions(props.scopes).workflow);
const workflowMenuItems = computed<ActionDropdownItem[]>(() => {
const actions: ActionDropdownItem[] = [
const workflowMenuItems = computed<Array<ActionDropdownItem<WORKFLOW_MENU_ACTIONS>>>(() => {
const actions: Array<ActionDropdownItem<WORKFLOW_MENU_ACTIONS>> = [
{
id: WORKFLOW_MENU_ACTIONS.DOWNLOAD,
label: locale.baseText('menuActions.download'),
@@ -434,8 +434,7 @@ async function handleFileImport(): Promise<void> {
}
}
async function onWorkflowMenuSelect(value: string): Promise<void> {
const action = value as WORKFLOW_MENU_ACTIONS;
async function onWorkflowMenuSelect(action: WORKFLOW_MENU_ACTIONS): Promise<void> {
switch (action) {
case WORKFLOW_MENU_ACTIONS.DUPLICATE: {
uiStore.openModalWithData({

View File

@@ -2,7 +2,7 @@ import { createTestingPinia } from '@pinia/testing';
import { screen } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { ROLE, type UsersList } from '@n8n/api-types';
import { ROLE, type Role, type UsersList } from '@n8n/api-types';
import { type ActionDropdownItem } from '@n8n/design-system';
import SettingsUsersRoleCell from '@/components/SettingsUsers/SettingsUsersRoleCell.vue';
import { createComponentRenderer } from '@/__tests__/render';
@@ -55,7 +55,7 @@ const mockRoles = {
[ROLE.Default]: { label: 'Default', desc: '' },
};
const mockActions: ActionDropdownItem[] = [
const mockActions: Array<ActionDropdownItem<Role | 'delete'>> = [
{ id: ROLE.Member, label: 'Member' },
{ id: ROLE.Admin, label: 'Admin' },
{ id: 'delete', label: 'Delete User', divided: true },

View File

@@ -6,20 +6,20 @@ import { type ActionDropdownItem, N8nActionDropdown, N8nIcon } from '@n8n/design
const props = defineProps<{
data: UsersList['items'][number];
roles: Record<Role, { label: string; desc: string }>;
actions: ActionDropdownItem[];
actions: Array<ActionDropdownItem<Role | 'delete'>>;
}>();
const emit = defineEmits<{
'update:role': [payload: { role: Role; userId: string }];
'update:role': [payload: { role: Role | 'delete'; userId: string }];
}>();
const selectedRole = ref<Role>(props.data.role ?? ROLE.Default);
const isEditable = computed(() => props.data.role !== ROLE.Owner);
const roleLabel = computed(() => props.roles[selectedRole.value].label);
const onActionSelect = (role: string) => {
const onActionSelect = (role: Role | 'delete') => {
emit('update:role', {
role: role as Role,
role,
userId: props.data.id,
});
};

View File

@@ -109,7 +109,7 @@ const roles = computed<Record<Role, { label: string; desc: string }>>(() => ({
},
[ROLE.Default]: { label: i18n.baseText('auth.roles.default'), desc: '' },
}));
const roleActions = computed<ActionDropdownItem[]>(() => [
const roleActions = computed<Array<ActionDropdownItem<Role | 'delete'>>>(() => [
{
id: ROLE.Member,
label: i18n.baseText('auth.roles.member'),

View File

@@ -4,7 +4,7 @@ import type { CanvasLayoutEvent, CanvasLayoutSource } from '@/composables/useCan
import { useCanvasLayout } from '@/composables/useCanvasLayout';
import { useCanvasNodeHover } from '@/composables/useCanvasNodeHover';
import { useCanvasTraversal } from '@/composables/useCanvasTraversal';
import type { ContextMenuAction, ContextMenuTarget } from '@/composables/useContextMenu';
import type { ContextMenuTarget } from '@/composables/useContextMenu';
import { useContextMenu } from '@/composables/useContextMenu';
import { type KeyMap, useKeybindings } from '@/composables/useKeybindings';
import type { PinDataSource } from '@/composables/usePinnedData';
@@ -56,6 +56,7 @@ import CanvasArrowHeadMarker from './elements/edges/CanvasArrowHeadMarker.vue';
import Edge from './elements/edges/CanvasEdge.vue';
import Node from './elements/nodes/CanvasNode.vue';
import { useExperimentalNdvStore } from './experimental/experimentalNdv.store';
import { type ContextMenuAction } from '@/composables/useContextMenuItems';
const $style = useCssModule();
@@ -908,6 +909,10 @@ provide(CanvasKey, {
isExperimentalNdvActive,
isPaneMoving,
});
defineExpose({
executeContextMenuAction: onContextMenuAction,
});
</script>
<template>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import Canvas from '@/components/canvas/Canvas.vue';
import { computed, ref, toRef, useCssModule } from 'vue';
import { computed, ref, toRef, useCssModule, useTemplateRef } from 'vue';
import type { Workflow } from 'n8n-workflow';
import type { IWorkflowDb } from '@/Interface';
import { useCanvasMapping } from '@/composables/useCanvasMapping';
@@ -9,6 +9,7 @@ import { createEventBus } from '@n8n/utils/event-bus';
import type { CanvasEventBusEvents } from '@/types';
import { useVueFlow } from '@vue-flow/core';
import { throttledRef } from '@vueuse/core';
import { type ContextMenuAction } from '@/composables/useContextMenuItems';
defineOptions({
inheritAttrs: false,
@@ -33,6 +34,7 @@ const props = withDefaults(
},
);
const canvasRef = useTemplateRef('canvas');
const $style = useCssModule();
const { onNodesInitialized } = useVueFlow(props.id);
@@ -63,6 +65,11 @@ onNodesInitialized(() => {
const mappedNodesThrottled = throttledRef(mappedNodes, 200);
const mappedConnectionsThrottled = throttledRef(mappedConnections, 200);
defineExpose({
executeContextMenuAction: (action: ContextMenuAction, nodeIds: string[]) =>
canvasRef.value?.executeContextMenuAction(action, nodeIds),
});
</script>
<template>
@@ -70,6 +77,7 @@ const mappedConnectionsThrottled = throttledRef(mappedConnections, 200);
<div id="canvas" :class="$style.canvas">
<Canvas
v-if="workflow"
ref="canvas"
:id="id"
:nodes="executing ? mappedNodesThrottled : mappedNodes"
:connections="executing ? mappedConnectionsThrottled : mappedConnections"

View File

@@ -50,7 +50,7 @@ const actions = computed(() =>
return aY === bY ? aX - bX : aY - bY;
})
.map<ActionDropdownItem>((node) => ({
.map<ActionDropdownItem<string>>((node) => ({
label: truncateBeforeLast(node.name, 50),
disabled: !!node.disabled || props.executing,
id: node.name,

View File

@@ -8,6 +8,7 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
import { createTestingPinia } from '@pinia/testing';
import ExperimentalNodeDetailsDrawer from './ExperimentalNodeDetailsDrawer.vue';
import { nextTick } from 'vue';
import { fireEvent } from '@testing-library/vue';
const renderComponent = createComponentRenderer(ExperimentalNodeDetailsDrawer);
@@ -62,7 +63,7 @@ describe('ExperimentalNodeDetailsDrawer', () => {
pinia,
props: {
node: mockNodes[0],
nodes: [mockNodes[0]],
nodeIds: ['node1'],
},
});
@@ -77,4 +78,42 @@ describe('ExperimentalNodeDetailsDrawer', () => {
await rendered.findByDisplayValue('after update');
});
describe('when multiple nodes are selected', () => {
it('should show the number of selected nodes and available actions', () => {
const rendered = renderComponent({
pinia,
props: {
node: mockNodes[0],
nodeIds: ['node1', 'node2'],
},
});
expect(rendered.getByText('2 nodes selected')).toBeInTheDocument();
const buttons = rendered.getAllByRole('button');
expect(buttons.length).toBeGreaterThan(0);
expect(rendered.getByText('Copy 2 nodes')).toBeInTheDocument();
expect(rendered.getByText('Duplicate 2 nodes')).toBeInTheDocument();
expect(rendered.getByText('Delete 2 nodes')).toBeInTheDocument();
});
it('should emit contextMenuAction event when a button is pressed', async () => {
const rendered = renderComponent({
pinia,
props: {
node: mockNodes[0],
nodeIds: ['node1', 'node2'],
},
});
const copyButton = rendered.getByText('Copy 2 nodes').closest('button')!;
await fireEvent.click(copyButton);
expect(rendered.emitted('contextMenuAction')).toBeTruthy();
expect(rendered.emitted('contextMenuAction')?.[0]).toEqual(['copy', ['node1', 'node2']]);
});
});
});

View File

@@ -1,18 +1,22 @@
<script setup lang="ts">
import { useExpressionResolveCtx } from '@/components/canvas/experimental/composables/useExpressionResolveCtx';
import { type ContextMenuAction, useContextMenuItems } from '@/composables/useContextMenuItems';
import { ExpressionLocalResolveContextSymbol } from '@/constants';
import { type INodeUi } from '@/Interface';
import { N8nText } from '@n8n/design-system';
import { type GraphNode } from '@vue-flow/core';
import { N8nButton, N8nKeyboardShortcut, N8nText } from '@n8n/design-system';
import { computed, provide, ref, watch } from 'vue';
import ExperimentalCanvasNodeSettings from './ExperimentalCanvasNodeSettings.vue';
import { useNDVStore } from '@/stores/ndv.store';
const { node, nodes } = defineProps<{ node: INodeUi; nodes: GraphNode[] }>();
const { node, nodeIds } = defineProps<{ node: INodeUi; nodeIds: string[] }>();
const emit = defineEmits<{ openNdv: [] }>();
const emit = defineEmits<{
openNdv: [];
contextMenuAction: [ContextMenuAction, nodeIds: string[]];
}>();
const expressionResolveCtx = useExpressionResolveCtx(computed(() => node));
const contextMenuItems = useContextMenuItems(computed(() => nodeIds));
const ndvStore = useNDVStore();
const ndvCloseTimes = ref(0);
@@ -35,7 +39,21 @@ provide(ExpressionLocalResolveContextSymbol, expressionResolveCtx);
<template>
<div :class="$style.component">
<N8nText v-if="nodes.length > 1" color="text-base"> {{ nodes.length }} nodes selected </N8nText>
<N8nText v-if="nodeIds.length > 1" tag="div" color="text-base" :class="$style.multipleNodes">
<div>{{ nodeIds.length }} nodes selected</div>
<ul :class="$style.multipleNodesActions">
<li v-for="action of contextMenuItems" :key="action.id" :class="$style.multipleNodesAction">
<N8nButton
type="secondary"
:disabled="action.disabled"
@click="emit('contextMenuAction', action.id, nodeIds)"
>
{{ action.label }}
<N8nKeyboardShortcut v-if="action.shortcut" v-bind="action.shortcut" />
</N8nButton>
</li>
</ul>
</N8nText>
<ExperimentalCanvasNodeSettings v-else-if="node" :key="nodeSettingsViewKey" :node-id="node.id">
<template #actions>
<N8nIconButton
@@ -54,10 +72,54 @@ provide(ExpressionLocalResolveContextSymbol, expressionResolveCtx);
<style lang="scss" module>
.component {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
overflow: auto;
}
.multipleNodes {
min-height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: start;
padding: var(--spacing-3xl) var(--spacing-m);
gap: var(--spacing-m);
}
.multipleNodesActions {
align-self: stretch;
list-style-type: none;
}
.multipleNodesAction {
margin-top: -1px;
& button {
width: 100%;
border-radius: 0;
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
border-color: var(--border-color-light);
&:disabled {
border-color: var(--border-color-light);
}
}
&:first-of-type button {
border-top-left-radius: var(--border-radius-base);
border-top-right-radius: var(--border-radius-base);
}
&:last-of-type button {
border-bottom-left-radius: var(--border-radius-base);
border-bottom-right-radius: var(--border-radius-base);
}
& button:hover {
z-index: 1;
}
}
</style>

View File

@@ -1,17 +1,8 @@
import type { ActionDropdownItem, XYPosition, INodeUi } from '@/Interface';
import { NOT_DUPLICATABLE_NODE_TYPES, STICKY_NODE_TYPE } from '@/constants';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import type { XYPosition } from '@/Interface';
import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type { INode, INodeTypeDescription, Workflow } from 'n8n-workflow';
import { NodeHelpers } from 'n8n-workflow';
import { computed, ref, watch } from 'vue';
import { getMousePosition } from '../utils/nodeViewUtils';
import { useI18n } from '@n8n/i18n';
import { usePinnedData } from './usePinnedData';
import { isPresent } from '../utils/typesUtils';
import { getResourcePermissions } from '@n8n/permissions';
import { useContextMenuItems, type ContextMenuAction } from './useContextMenuItems';
export type ContextMenuTarget =
| { source: 'canvas'; nodeIds: string[]; nodeId?: string }
@@ -19,105 +10,25 @@ export type ContextMenuTarget =
| { source: 'node-button'; nodeId: string };
export type ContextMenuActionCallback = (action: ContextMenuAction, nodeIds: string[]) => void;
export type ContextMenuAction =
| 'open'
| 'copy'
| 'toggle_activation'
| 'duplicate'
| 'execute'
| 'rename'
| 'toggle_pin'
| 'delete'
| 'select_all'
| 'deselect_all'
| 'add_node'
| 'add_sticky'
| 'change_color'
| 'open_sub_workflow'
| 'tidy_up'
| 'extract_sub_workflow';
const position = ref<XYPosition>([0, 0]);
const isOpen = ref(false);
const target = ref<ContextMenuTarget>();
const actions = ref<ActionDropdownItem[]>([]);
const actionCallback = ref<ContextMenuActionCallback>(() => {});
export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) => {
export const useContextMenu = () => {
const uiStore = useUIStore();
const nodeTypesStore = useNodeTypesStore();
const workflowsStore = useWorkflowsStore();
const sourceControlStore = useSourceControlStore();
const i18n = useI18n();
const workflowObject = computed(() => workflowsStore.workflowObject as Workflow);
const workflowPermissions = computed(
() => getResourcePermissions(workflowsStore.workflow.scopes).workflow,
);
const isReadOnly = computed(
() =>
sourceControlStore.preferences.branchReadOnly ||
uiStore.isReadOnlyView ||
!workflowPermissions.value.update ||
workflowsStore.workflow.isArchived,
);
const canOpenSubworkflow = computed(() => {
if (targetNodes.value.length !== 1) return false;
const node = targetNodes.value[0];
if (!NodeHelpers.isNodeWithWorkflowSelector(node)) return false;
return !!NodeHelpers.getSubworkflowId(node);
});
const isOpen = computed(() => target.value !== undefined);
const targetNodeIds = computed(() => {
if (!isOpen.value || !target.value) return [];
if (!target.value) return [];
const currentTarget = target.value;
return currentTarget.source === 'canvas' ? currentTarget.nodeIds : [currentTarget.nodeId];
});
const targetNodes = computed(() =>
targetNodeIds.value.map((nodeId) => workflowsStore.getNodeById(nodeId)).filter(isPresent),
);
const canAddNodeOfType = (nodeType: INodeTypeDescription) => {
const sameTypeNodes = workflowsStore.allNodes.filter((n) => n.type === nodeType.name);
return nodeType.maxNodes === undefined || sameTypeNodes.length < nodeType.maxNodes;
};
const canDuplicateNode = (node: INode): boolean => {
const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion);
if (!nodeType) return false;
if (NOT_DUPLICATABLE_NODE_TYPES.includes(nodeType.name)) return false;
return canAddNodeOfType(nodeType);
};
const hasPinData = (node: INode): boolean => {
return !!workflowsStore.pinDataByNodeName(node.name);
};
const close = () => {
target.value = undefined;
isOpen.value = false;
actions.value = [];
position.value = [0, 0];
};
const isExecutable = (node: INodeUi) => {
const workflowNode = workflowObject.value.getNode(node.name) as INode;
const nodeType = nodeTypesStore.getNodeType(
workflowNode.type,
workflowNode.typeVersion,
) as INodeTypeDescription;
return NodeHelpers.isExecutable(workflowObject.value, workflowNode, nodeType);
};
const open = (event: MouseEvent, menuTarget: ContextMenuTarget) => {
event.stopPropagation();
@@ -133,186 +44,21 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
event.preventDefault();
actionCallback.value = onAction;
target.value = menuTarget;
position.value = getMousePosition(event);
isOpen.value = true;
const nodes = targetNodes.value;
const onlyStickies = nodes.every((node) => node.type === STICKY_NODE_TYPE);
const i18nOptions = {
adjustToNumber: nodes.length,
interpolate: {
subject: onlyStickies
? i18n.baseText('contextMenu.sticky', { adjustToNumber: nodes.length })
: i18n.baseText('contextMenu.node', { adjustToNumber: nodes.length }),
},
};
const selectionActions: ActionDropdownItem[] = [
{
id: 'select_all',
divided: true,
label: i18n.baseText('contextMenu.selectAll'),
shortcut: { metaKey: true, keys: ['A'] },
disabled: nodes.length === workflowsStore.allNodes.length,
},
{
id: 'deselect_all',
label: i18n.baseText('contextMenu.deselectAll'),
disabled: nodes.length === 0,
},
];
const extractionActions: ActionDropdownItem[] = [
{
id: 'extract_sub_workflow',
divided: true,
label: i18n.baseText('contextMenu.extract', { adjustToNumber: nodes.length }),
shortcut: { altKey: true, keys: ['X'] },
disabled: isReadOnly.value,
},
];
const layoutActions: ActionDropdownItem[] = [
{
id: 'tidy_up',
divided: true,
label: i18n.baseText(
nodes.length < 2 ? 'contextMenu.tidyUpWorkflow' : 'contextMenu.tidyUpSelection',
),
shortcut: { shiftKey: true, altKey: true, keys: ['T'] },
},
];
if (nodes.length === 0) {
actions.value = [
{
id: 'add_node',
shortcut: { keys: ['Tab'] },
label: i18n.baseText('contextMenu.addNode'),
disabled: isReadOnly.value,
},
{
id: 'add_sticky',
shortcut: { shiftKey: true, keys: ['s'] },
label: i18n.baseText('contextMenu.addSticky'),
disabled: isReadOnly.value,
},
...layoutActions,
...selectionActions,
];
} else {
const menuActions: ActionDropdownItem[] = [
!onlyStickies && {
id: 'toggle_activation',
label: nodes.every((node) => node.disabled)
? i18n.baseText('contextMenu.activate', i18nOptions)
: i18n.baseText('contextMenu.deactivate', i18nOptions),
shortcut: { keys: ['D'] },
disabled: isReadOnly.value,
},
!onlyStickies && {
id: 'toggle_pin',
label: nodes.every((node) => hasPinData(node))
? i18n.baseText('contextMenu.unpin', i18nOptions)
: i18n.baseText('contextMenu.pin', i18nOptions),
shortcut: { keys: ['p'] },
disabled: isReadOnly.value || !nodes.every((n) => usePinnedData(n).canPinNode(true)),
},
{
id: 'copy',
label: i18n.baseText('contextMenu.copy', i18nOptions),
shortcut: { metaKey: true, keys: ['C'] },
},
{
id: 'duplicate',
label: i18n.baseText('contextMenu.duplicate', i18nOptions),
shortcut: { metaKey: true, keys: ['D'] },
disabled: isReadOnly.value || !nodes.every(canDuplicateNode),
},
...layoutActions,
...extractionActions,
...selectionActions,
{
id: 'delete',
divided: true,
label: i18n.baseText('contextMenu.delete', i18nOptions),
shortcut: { keys: ['Del'] },
disabled: isReadOnly.value,
},
].filter(Boolean) as ActionDropdownItem[];
if (nodes.length === 1) {
const singleNodeActions: ActionDropdownItem[] = onlyStickies
? [
{
id: 'open',
label: i18n.baseText('contextMenu.editSticky'),
shortcut: { keys: ['↵'] },
disabled: isReadOnly.value,
},
{
id: 'change_color',
label: i18n.baseText('contextMenu.changeColor'),
disabled: isReadOnly.value,
},
]
: [
{
id: 'open',
label: i18n.baseText('contextMenu.open'),
shortcut: { keys: ['↵'] },
},
{
id: 'execute',
label: i18n.baseText('contextMenu.test'),
disabled: isReadOnly.value || !isExecutable(nodes[0]),
},
{
id: 'rename',
label: i18n.baseText('contextMenu.rename'),
shortcut: { keys: ['Space'] },
disabled: isReadOnly.value,
},
];
if (NodeHelpers.isNodeWithWorkflowSelector(nodes[0])) {
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);
}
actions.value = menuActions;
}
};
const _dispatchAction = (a: ContextMenuAction) => {
actionCallback.value(a, targetNodeIds.value);
};
const actions = useContextMenuItems(targetNodeIds);
watch(
() => uiStore.nodeViewOffsetPosition,
() => {
close();
},
);
watch(() => uiStore.nodeViewOffsetPosition, close);
return {
isOpen,
position,
target,
actions,
actions: computed(() => (isOpen.value ? actions.value : [])),
targetNodeIds,
open,
close,
_dispatchAction,
};
};

View File

@@ -0,0 +1,252 @@
import type { ActionDropdownItem, INodeUi } from '@/Interface';
import { NOT_DUPLICATABLE_NODE_TYPES, STICKY_NODE_TYPE } from '@/constants';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useI18n } from '@n8n/i18n';
import { getResourcePermissions } from '@n8n/permissions';
import type { INode, INodeTypeDescription, Workflow } from 'n8n-workflow';
import { NodeHelpers } from 'n8n-workflow';
import { computed, type ComputedRef } from 'vue';
import { isPresent } from '../utils/typesUtils';
import { usePinnedData } from './usePinnedData';
export type ContextMenuAction =
| 'open'
| 'copy'
| 'toggle_activation'
| 'duplicate'
| 'execute'
| 'rename'
| 'toggle_pin'
| 'delete'
| 'select_all'
| 'deselect_all'
| 'add_node'
| 'add_sticky'
| 'change_color'
| 'open_sub_workflow'
| 'tidy_up'
| 'extract_sub_workflow';
type Item = ActionDropdownItem<ContextMenuAction>;
export function useContextMenuItems(targetNodeIds: ComputedRef<string[]>): ComputedRef<Item[]> {
const uiStore = useUIStore();
const nodeTypesStore = useNodeTypesStore();
const workflowsStore = useWorkflowsStore();
const sourceControlStore = useSourceControlStore();
const i18n = useI18n();
const workflowObject = computed(() => workflowsStore.workflowObject as Workflow);
const workflowPermissions = computed(
() => getResourcePermissions(workflowsStore.workflow.scopes).workflow,
);
const isReadOnly = computed(
() =>
sourceControlStore.preferences.branchReadOnly ||
uiStore.isReadOnlyView ||
!workflowPermissions.value.update ||
workflowsStore.workflow.isArchived,
);
const canOpenSubworkflow = computed(() => {
if (targetNodes.value.length !== 1) return false;
const node = targetNodes.value[0];
if (!NodeHelpers.isNodeWithWorkflowSelector(node)) return false;
return !!NodeHelpers.getSubworkflowId(node);
});
const targetNodes = computed(() =>
targetNodeIds.value.map((nodeId) => workflowsStore.getNodeById(nodeId)).filter(isPresent),
);
const canAddNodeOfType = (nodeType: INodeTypeDescription) => {
const sameTypeNodes = workflowsStore.allNodes.filter((n) => n.type === nodeType.name);
return nodeType.maxNodes === undefined || sameTypeNodes.length < nodeType.maxNodes;
};
const canDuplicateNode = (node: INode): boolean => {
const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion);
if (!nodeType) return false;
if (NOT_DUPLICATABLE_NODE_TYPES.includes(nodeType.name)) return false;
return canAddNodeOfType(nodeType);
};
const hasPinData = (node: INode): boolean => {
return !!workflowsStore.pinDataByNodeName(node.name);
};
const isExecutable = (node: INodeUi) => {
const workflowNode = workflowObject.value.getNode(node.name) as INode;
const nodeType = nodeTypesStore.getNodeType(
workflowNode.type,
workflowNode.typeVersion,
) as INodeTypeDescription;
return NodeHelpers.isExecutable(workflowObject.value, workflowNode, nodeType);
};
return computed(() => {
const nodes = targetNodes.value;
const onlyStickies = nodes.every((node) => node.type === STICKY_NODE_TYPE);
const i18nOptions = {
adjustToNumber: nodes.length,
interpolate: {
subject: onlyStickies
? i18n.baseText('contextMenu.sticky', { adjustToNumber: nodes.length })
: i18n.baseText('contextMenu.node', { adjustToNumber: nodes.length }),
},
};
const selectionActions: Item[] = [
{
id: 'select_all',
divided: true,
label: i18n.baseText('contextMenu.selectAll'),
shortcut: { metaKey: true, keys: ['A'] },
disabled: nodes.length === workflowsStore.allNodes.length,
},
{
id: 'deselect_all',
label: i18n.baseText('contextMenu.deselectAll'),
disabled: nodes.length === 0,
},
];
const extractionActions: Item[] = [
{
id: 'extract_sub_workflow',
divided: true,
label: i18n.baseText('contextMenu.extract', { adjustToNumber: nodes.length }),
shortcut: { altKey: true, keys: ['X'] },
disabled: isReadOnly.value,
},
];
const layoutActions: Item[] = [
{
id: 'tidy_up',
divided: true,
label: i18n.baseText(
nodes.length < 2 ? 'contextMenu.tidyUpWorkflow' : 'contextMenu.tidyUpSelection',
),
shortcut: { shiftKey: true, altKey: true, keys: ['T'] },
},
];
if (nodes.length === 0) {
return [
{
id: 'add_node',
shortcut: { keys: ['Tab'] },
label: i18n.baseText('contextMenu.addNode'),
disabled: isReadOnly.value,
},
{
id: 'add_sticky',
shortcut: { shiftKey: true, keys: ['s'] },
label: i18n.baseText('contextMenu.addSticky'),
disabled: isReadOnly.value,
},
...layoutActions,
...selectionActions,
];
} else {
const menuActions: Item[] = [
!onlyStickies && {
id: 'toggle_activation',
label: nodes.every((node) => node.disabled)
? i18n.baseText('contextMenu.activate', i18nOptions)
: i18n.baseText('contextMenu.deactivate', i18nOptions),
shortcut: { keys: ['D'] },
disabled: isReadOnly.value,
},
!onlyStickies && {
id: 'toggle_pin',
label: nodes.every((node) => hasPinData(node))
? i18n.baseText('contextMenu.unpin', i18nOptions)
: i18n.baseText('contextMenu.pin', i18nOptions),
shortcut: { keys: ['p'] },
disabled: isReadOnly.value || !nodes.every((n) => usePinnedData(n).canPinNode(true)),
},
{
id: 'copy',
label: i18n.baseText('contextMenu.copy', i18nOptions),
shortcut: { metaKey: true, keys: ['C'] },
},
{
id: 'duplicate',
label: i18n.baseText('contextMenu.duplicate', i18nOptions),
shortcut: { metaKey: true, keys: ['D'] },
disabled: isReadOnly.value || !nodes.every(canDuplicateNode),
},
...layoutActions,
...extractionActions,
...selectionActions,
{
id: 'delete',
divided: true,
label: i18n.baseText('contextMenu.delete', i18nOptions),
shortcut: { keys: ['Del'] },
disabled: isReadOnly.value,
},
].filter(Boolean) as Item[];
if (nodes.length === 1) {
const singleNodeActions: Item[] = onlyStickies
? [
{
id: 'open',
label: i18n.baseText('contextMenu.editSticky'),
shortcut: { keys: ['↵'] },
disabled: isReadOnly.value,
},
{
id: 'change_color',
label: i18n.baseText('contextMenu.changeColor'),
disabled: isReadOnly.value,
},
]
: [
{
id: 'open',
label: i18n.baseText('contextMenu.open'),
shortcut: { keys: ['↵'] },
},
{
id: 'execute',
label: i18n.baseText('contextMenu.test'),
disabled: isReadOnly.value || !isExecutable(nodes[0]),
},
{
id: 'rename',
label: i18n.baseText('contextMenu.rename'),
shortcut: { keys: ['Space'] },
disabled: isReadOnly.value,
},
];
if (NodeHelpers.isNodeWithWorkflowSelector(nodes[0])) {
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);
}
return menuActions;
}
});
}

View File

@@ -20,14 +20,14 @@ const i18n = useI18n();
const isHovered = ref(false);
const isDropdownOpen = ref(false);
const dropdownRef = ref<InstanceType<typeof N8nActionDropdown>>();
const dropdownRef = ref<{ $el?: Node }>();
const enum ItemAction {
Delete = 'delete',
}
const onItemClick = (action: string) => {
if (action === (ItemAction.Delete as string)) {
const onItemClick = (action: ItemAction) => {
if (action === ItemAction.Delete) {
props.params.onDelete(props.params.column.getColId());
}
};

View File

@@ -12,6 +12,7 @@ import {
watch,
h,
onBeforeUnmount,
useTemplateRef,
} from 'vue';
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router';
import WorkflowCanvas from '@/components/canvas/WorkflowCanvas.vue';
@@ -144,6 +145,7 @@ import { useFocusPanelStore } from '@/stores/focusPanel.store';
import { useAITemplatesStarterCollectionStore } from '@/experiments/aiTemplatesStarterCollection/stores/aiTemplatesStarterCollection.store';
import { useReadyToRunWorkflowsStore } from '@/experiments/readyToRunWorkflows/stores/readyToRunWorkflows.store';
import { useKeybindings } from '@/composables/useKeybindings';
import { type ContextMenuAction } from '@/composables/useContextMenuItems';
defineOptions({
name: 'NodeView',
@@ -263,6 +265,7 @@ useKeybindings({
ctrl_alt_o: () => uiStore.openModal(ABOUT_MODAL_KEY),
});
const canvasRef = useTemplateRef('canvas');
const isLoading = ref(true);
const isBlankRedirect = ref(false);
const readOnlyNotification = ref<null | { visible: boolean }>(null);
@@ -885,6 +888,10 @@ async function onSaveWorkflow() {
}
}
function onContextMenuAction(action: ContextMenuAction, nodeIds: string[]) {
canvasRef.value?.executeContextMenuAction(action, nodeIds);
}
function addWorkflowSavedEventBindings() {
canvasEventBus.on('saved:workflow', npsSurveyStore.fetchPromptsData);
canvasEventBus.on('saved:workflow', onSaveFromWithinNDV);
@@ -2043,6 +2050,7 @@ onBeforeUnmount(() => {
<div :class="$style.wrapper">
<WorkflowCanvas
v-if="editableWorkflow && editableWorkflowObject && !isLoading"
ref="canvas"
:id="editableWorkflow.id"
:workflow="editableWorkflow"
:workflow-object="editableWorkflowObject"
@@ -2202,6 +2210,7 @@ onBeforeUnmount(() => {
v-if="!isLoading"
:is-canvas-read-only="isCanvasReadOnly"
@save-keyboard-shortcut="onSaveWorkflow"
@context-menu-action="onContextMenuAction"
/>
</div>
</template>