mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 01:56:46 +00:00
feat(editor): Show actions in focus panel when multiple nodes are selected (no-changelog) (#18984)
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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']]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user