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

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