mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
feat(editor): Show actions in focus panel when multiple nodes are selected (no-changelog) (#18984)
This commit is contained in:
@@ -30,7 +30,9 @@ const template: StoryFn = (args, { argTypes }) => ({
|
|||||||
setup: () => ({ args }),
|
setup: () => ({ args }),
|
||||||
props: Object.keys(argTypes),
|
props: Object.keys(argTypes),
|
||||||
components: {
|
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" />',
|
template: '<n8n-action-dropdown v-bind="args" />',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
// This component is visually similar to the ActionToggle component
|
||||||
// but it offers more options when it comes to dropdown items styling
|
// but it offers more options when it comes to dropdown items styling
|
||||||
// (supports icons, separators, custom styling and all options provided
|
// (supports icons, separators, custom styling and all options provided
|
||||||
@@ -19,7 +19,7 @@ import { N8nKeyboardShortcut } from '../N8nKeyboardShortcut';
|
|||||||
const TRIGGER = ['click', 'hover'] as const;
|
const TRIGGER = ['click', 'hover'] as const;
|
||||||
|
|
||||||
interface ActionDropdownProps {
|
interface ActionDropdownProps {
|
||||||
items: ActionDropdownItem[];
|
items: Array<ActionDropdownItem<T>>;
|
||||||
placement?: Placement;
|
placement?: Placement;
|
||||||
activatorIcon?: IconName;
|
activatorIcon?: IconName;
|
||||||
activatorSize?: ButtonSize;
|
activatorSize?: ButtonSize;
|
||||||
@@ -46,7 +46,7 @@ const attrs = useAttrs();
|
|||||||
const testIdPrefix = attrs['data-test-id'];
|
const testIdPrefix = attrs['data-test-id'];
|
||||||
|
|
||||||
const $style = useCssModule();
|
const $style = useCssModule();
|
||||||
const getItemClasses = (item: ActionDropdownItem): Record<string, boolean> => {
|
const getItemClasses = (item: ActionDropdownItem<T>): Record<string, boolean> => {
|
||||||
return {
|
return {
|
||||||
[$style.itemContainer]: true,
|
[$style.itemContainer]: true,
|
||||||
[$style.disabled]: !!item.disabled,
|
[$style.disabled]: !!item.disabled,
|
||||||
@@ -56,13 +56,13 @@ const getItemClasses = (item: ActionDropdownItem): Record<string, boolean> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
select: [action: string];
|
select: [action: T];
|
||||||
visibleChange: [open: boolean];
|
visibleChange: [open: boolean];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
defineSlots<{
|
defineSlots<{
|
||||||
activator: {};
|
activator: {};
|
||||||
menuItem: (props: ActionDropdownItem) => void;
|
menuItem: (props: ActionDropdownItem<T>) => void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const elementDropdown = ref<InstanceType<typeof ElDropdown>>();
|
const elementDropdown = ref<InstanceType<typeof ElDropdown>>();
|
||||||
@@ -72,7 +72,7 @@ const popperClass = computed(
|
|||||||
`${$style.shadow}${props.hideArrow ? ` ${$style.hideArrow}` : ''} ${props.extraPopperClass ?? ''}`,
|
`${$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 onVisibleChange = (open: boolean) => emit('visibleChange', open);
|
||||||
|
|
||||||
const onButtonBlur = (event: FocusEvent) => {
|
const onButtonBlur = (event: FocusEvent) => {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import type { KeyboardShortcut } from '@n8n/design-system/types/keyboardshortcut
|
|||||||
|
|
||||||
import type { IconName } from '../components/N8nIcon/icons';
|
import type { IconName } from '../components/N8nIcon/icons';
|
||||||
|
|
||||||
export interface ActionDropdownItem {
|
export interface ActionDropdownItem<T extends string> {
|
||||||
id: string;
|
id: T;
|
||||||
label: string;
|
label: string;
|
||||||
badge?: string;
|
badge?: string;
|
||||||
badgeProps?: Record<string, unknown>;
|
badgeProps?: Record<string, unknown>;
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
<script lang="ts" setup>
|
<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 { useStyles } from '@/composables/useStyles';
|
||||||
import { N8nActionDropdown } from '@n8n/design-system';
|
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 contextMenu = useContextMenu();
|
||||||
const { position, isOpen, actions, target } = contextMenu;
|
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 emit = defineEmits<{ action: [action: ContextMenuAction, nodeIds: string[]] }>();
|
||||||
const { APP_Z_INDEXES } = useStyles();
|
const { APP_Z_INDEXES } = useStyles();
|
||||||
|
|
||||||
@@ -22,10 +24,8 @@ watch(
|
|||||||
{ flush: 'post' },
|
{ flush: 'post' },
|
||||||
);
|
);
|
||||||
|
|
||||||
function onActionSelect(item: string) {
|
function onActionSelect(item: ContextMenuAction) {
|
||||||
const action = item as ContextMenuAction;
|
emit('action', item, contextMenu.targetNodeIds.value);
|
||||||
contextMenu._dispatchAction(action);
|
|
||||||
emit('action', action, contextMenu.targetNodeIds.value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onVisibleChange(open: boolean) {
|
function onVisibleChange(open: boolean) {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { useNDVStore } from '@/stores/ndv.store';
|
|||||||
import { useVueFlow } from '@vue-flow/core';
|
import { useVueFlow } from '@vue-flow/core';
|
||||||
import ExperimentalFocusPanelHeader from '@/components/canvas/experimental/components/ExperimentalFocusPanelHeader.vue';
|
import ExperimentalFocusPanelHeader from '@/components/canvas/experimental/components/ExperimentalFocusPanelHeader.vue';
|
||||||
import { useTelemetryContext } from '@/composables/useTelemetryContext';
|
import { useTelemetryContext } from '@/composables/useTelemetryContext';
|
||||||
|
import { type ContextMenuAction } from '@/composables/useContextMenuItems';
|
||||||
|
|
||||||
defineOptions({ name: 'FocusPanel' });
|
defineOptions({ name: 'FocusPanel' });
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ const props = defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
focus: [];
|
focus: [];
|
||||||
saveKeyboardShortcut: [event: KeyboardEvent];
|
saveKeyboardShortcut: [event: KeyboardEvent];
|
||||||
|
contextMenuAction: [action: ContextMenuAction, nodeIds: string[]];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// ESLint: false positive
|
// ESLint: false positive
|
||||||
@@ -205,6 +207,8 @@ const targetNodeParameterContext = computed<TargetNodeParameterContext | undefin
|
|||||||
|
|
||||||
const isNodeExecuting = computed(() => workflowsStore.isNodeExecuting(node.value?.name ?? ''));
|
const isNodeExecuting = computed(() => workflowsStore.isNodeExecuting(node.value?.name ?? ''));
|
||||||
|
|
||||||
|
const selectedNodeIds = computed(() => vueFlow.getSelectedNodes.value.map((n) => n.id));
|
||||||
|
|
||||||
const { resolvedExpression } = useResolvedExpression({
|
const { resolvedExpression } = useResolvedExpression({
|
||||||
expression,
|
expression,
|
||||||
additionalData: resolvedAdditionalExpressionData,
|
additionalData: resolvedAdditionalExpressionData,
|
||||||
@@ -571,8 +575,9 @@ function onOpenNdv() {
|
|||||||
<ExperimentalNodeDetailsDrawer
|
<ExperimentalNodeDetailsDrawer
|
||||||
v-else-if="node && experimentalNdvStore.isNdvInFocusPanelEnabled"
|
v-else-if="node && experimentalNdvStore.isNdvInFocusPanelEnabled"
|
||||||
:node="node"
|
:node="node"
|
||||||
:nodes="vueFlow.getSelectedNodes.value"
|
:node-ids="selectedNodeIds"
|
||||||
@open-ndv="onOpenNdv"
|
@open-ndv="onOpenNdv"
|
||||||
|
@context-menu-action="(action, nodeIds) => emit('contextMenuAction', action, nodeIds)"
|
||||||
/>
|
/>
|
||||||
<div v-else :class="[$style.content, $style.emptyContent]">
|
<div v-else :class="[$style.content, $style.emptyContent]">
|
||||||
<div :class="$style.emptyText">
|
<div :class="$style.emptyText">
|
||||||
|
|||||||
@@ -147,8 +147,8 @@ const onExecutionsTab = computed(() => {
|
|||||||
|
|
||||||
const workflowPermissions = computed(() => getResourcePermissions(props.scopes).workflow);
|
const workflowPermissions = computed(() => getResourcePermissions(props.scopes).workflow);
|
||||||
|
|
||||||
const workflowMenuItems = computed<ActionDropdownItem[]>(() => {
|
const workflowMenuItems = computed<Array<ActionDropdownItem<WORKFLOW_MENU_ACTIONS>>>(() => {
|
||||||
const actions: ActionDropdownItem[] = [
|
const actions: Array<ActionDropdownItem<WORKFLOW_MENU_ACTIONS>> = [
|
||||||
{
|
{
|
||||||
id: WORKFLOW_MENU_ACTIONS.DOWNLOAD,
|
id: WORKFLOW_MENU_ACTIONS.DOWNLOAD,
|
||||||
label: locale.baseText('menuActions.download'),
|
label: locale.baseText('menuActions.download'),
|
||||||
@@ -434,8 +434,7 @@ async function handleFileImport(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onWorkflowMenuSelect(value: string): Promise<void> {
|
async function onWorkflowMenuSelect(action: WORKFLOW_MENU_ACTIONS): Promise<void> {
|
||||||
const action = value as WORKFLOW_MENU_ACTIONS;
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case WORKFLOW_MENU_ACTIONS.DUPLICATE: {
|
case WORKFLOW_MENU_ACTIONS.DUPLICATE: {
|
||||||
uiStore.openModalWithData({
|
uiStore.openModalWithData({
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createTestingPinia } from '@pinia/testing';
|
|||||||
import { screen } from '@testing-library/vue';
|
import { screen } from '@testing-library/vue';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { vi } from 'vitest';
|
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 { type ActionDropdownItem } from '@n8n/design-system';
|
||||||
import SettingsUsersRoleCell from '@/components/SettingsUsers/SettingsUsersRoleCell.vue';
|
import SettingsUsersRoleCell from '@/components/SettingsUsers/SettingsUsersRoleCell.vue';
|
||||||
import { createComponentRenderer } from '@/__tests__/render';
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
@@ -55,7 +55,7 @@ const mockRoles = {
|
|||||||
[ROLE.Default]: { label: 'Default', desc: '' },
|
[ROLE.Default]: { label: 'Default', desc: '' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockActions: ActionDropdownItem[] = [
|
const mockActions: Array<ActionDropdownItem<Role | 'delete'>> = [
|
||||||
{ id: ROLE.Member, label: 'Member' },
|
{ id: ROLE.Member, label: 'Member' },
|
||||||
{ id: ROLE.Admin, label: 'Admin' },
|
{ id: ROLE.Admin, label: 'Admin' },
|
||||||
{ id: 'delete', label: 'Delete User', divided: true },
|
{ id: 'delete', label: 'Delete User', divided: true },
|
||||||
|
|||||||
@@ -6,20 +6,20 @@ import { type ActionDropdownItem, N8nActionDropdown, N8nIcon } from '@n8n/design
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
data: UsersList['items'][number];
|
data: UsersList['items'][number];
|
||||||
roles: Record<Role, { label: string; desc: string }>;
|
roles: Record<Role, { label: string; desc: string }>;
|
||||||
actions: ActionDropdownItem[];
|
actions: Array<ActionDropdownItem<Role | 'delete'>>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
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 selectedRole = ref<Role>(props.data.role ?? ROLE.Default);
|
||||||
const isEditable = computed(() => props.data.role !== ROLE.Owner);
|
const isEditable = computed(() => props.data.role !== ROLE.Owner);
|
||||||
const roleLabel = computed(() => props.roles[selectedRole.value].label);
|
const roleLabel = computed(() => props.roles[selectedRole.value].label);
|
||||||
|
|
||||||
const onActionSelect = (role: string) => {
|
const onActionSelect = (role: Role | 'delete') => {
|
||||||
emit('update:role', {
|
emit('update:role', {
|
||||||
role: role as Role,
|
role,
|
||||||
userId: props.data.id,
|
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: '' },
|
[ROLE.Default]: { label: i18n.baseText('auth.roles.default'), desc: '' },
|
||||||
}));
|
}));
|
||||||
const roleActions = computed<ActionDropdownItem[]>(() => [
|
const roleActions = computed<Array<ActionDropdownItem<Role | 'delete'>>>(() => [
|
||||||
{
|
{
|
||||||
id: ROLE.Member,
|
id: ROLE.Member,
|
||||||
label: i18n.baseText('auth.roles.member'),
|
label: i18n.baseText('auth.roles.member'),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { CanvasLayoutEvent, CanvasLayoutSource } from '@/composables/useCan
|
|||||||
import { useCanvasLayout } from '@/composables/useCanvasLayout';
|
import { useCanvasLayout } from '@/composables/useCanvasLayout';
|
||||||
import { useCanvasNodeHover } from '@/composables/useCanvasNodeHover';
|
import { useCanvasNodeHover } from '@/composables/useCanvasNodeHover';
|
||||||
import { useCanvasTraversal } from '@/composables/useCanvasTraversal';
|
import { useCanvasTraversal } from '@/composables/useCanvasTraversal';
|
||||||
import type { ContextMenuAction, ContextMenuTarget } from '@/composables/useContextMenu';
|
import type { ContextMenuTarget } from '@/composables/useContextMenu';
|
||||||
import { useContextMenu } from '@/composables/useContextMenu';
|
import { useContextMenu } from '@/composables/useContextMenu';
|
||||||
import { type KeyMap, useKeybindings } from '@/composables/useKeybindings';
|
import { type KeyMap, useKeybindings } from '@/composables/useKeybindings';
|
||||||
import type { PinDataSource } from '@/composables/usePinnedData';
|
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 Edge from './elements/edges/CanvasEdge.vue';
|
||||||
import Node from './elements/nodes/CanvasNode.vue';
|
import Node from './elements/nodes/CanvasNode.vue';
|
||||||
import { useExperimentalNdvStore } from './experimental/experimentalNdv.store';
|
import { useExperimentalNdvStore } from './experimental/experimentalNdv.store';
|
||||||
|
import { type ContextMenuAction } from '@/composables/useContextMenuItems';
|
||||||
|
|
||||||
const $style = useCssModule();
|
const $style = useCssModule();
|
||||||
|
|
||||||
@@ -908,6 +909,10 @@ provide(CanvasKey, {
|
|||||||
isExperimentalNdvActive,
|
isExperimentalNdvActive,
|
||||||
isPaneMoving,
|
isPaneMoving,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
executeContextMenuAction: onContextMenuAction,
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Canvas from '@/components/canvas/Canvas.vue';
|
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 { Workflow } from 'n8n-workflow';
|
||||||
import type { IWorkflowDb } from '@/Interface';
|
import type { IWorkflowDb } from '@/Interface';
|
||||||
import { useCanvasMapping } from '@/composables/useCanvasMapping';
|
import { useCanvasMapping } from '@/composables/useCanvasMapping';
|
||||||
@@ -9,6 +9,7 @@ import { createEventBus } from '@n8n/utils/event-bus';
|
|||||||
import type { CanvasEventBusEvents } from '@/types';
|
import type { CanvasEventBusEvents } from '@/types';
|
||||||
import { useVueFlow } from '@vue-flow/core';
|
import { useVueFlow } from '@vue-flow/core';
|
||||||
import { throttledRef } from '@vueuse/core';
|
import { throttledRef } from '@vueuse/core';
|
||||||
|
import { type ContextMenuAction } from '@/composables/useContextMenuItems';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
@@ -33,6 +34,7 @@ const props = withDefaults(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const canvasRef = useTemplateRef('canvas');
|
||||||
const $style = useCssModule();
|
const $style = useCssModule();
|
||||||
|
|
||||||
const { onNodesInitialized } = useVueFlow(props.id);
|
const { onNodesInitialized } = useVueFlow(props.id);
|
||||||
@@ -63,6 +65,11 @@ onNodesInitialized(() => {
|
|||||||
|
|
||||||
const mappedNodesThrottled = throttledRef(mappedNodes, 200);
|
const mappedNodesThrottled = throttledRef(mappedNodes, 200);
|
||||||
const mappedConnectionsThrottled = throttledRef(mappedConnections, 200);
|
const mappedConnectionsThrottled = throttledRef(mappedConnections, 200);
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
executeContextMenuAction: (action: ContextMenuAction, nodeIds: string[]) =>
|
||||||
|
canvasRef.value?.executeContextMenuAction(action, nodeIds),
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -70,6 +77,7 @@ const mappedConnectionsThrottled = throttledRef(mappedConnections, 200);
|
|||||||
<div id="canvas" :class="$style.canvas">
|
<div id="canvas" :class="$style.canvas">
|
||||||
<Canvas
|
<Canvas
|
||||||
v-if="workflow"
|
v-if="workflow"
|
||||||
|
ref="canvas"
|
||||||
:id="id"
|
:id="id"
|
||||||
:nodes="executing ? mappedNodesThrottled : mappedNodes"
|
:nodes="executing ? mappedNodesThrottled : mappedNodes"
|
||||||
:connections="executing ? mappedConnectionsThrottled : mappedConnections"
|
:connections="executing ? mappedConnectionsThrottled : mappedConnections"
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const actions = computed(() =>
|
|||||||
|
|
||||||
return aY === bY ? aX - bX : aY - bY;
|
return aY === bY ? aX - bX : aY - bY;
|
||||||
})
|
})
|
||||||
.map<ActionDropdownItem>((node) => ({
|
.map<ActionDropdownItem<string>>((node) => ({
|
||||||
label: truncateBeforeLast(node.name, 50),
|
label: truncateBeforeLast(node.name, 50),
|
||||||
disabled: !!node.disabled || props.executing,
|
disabled: !!node.disabled || props.executing,
|
||||||
id: node.name,
|
id: node.name,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
|
|||||||
import { createTestingPinia } from '@pinia/testing';
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
import ExperimentalNodeDetailsDrawer from './ExperimentalNodeDetailsDrawer.vue';
|
import ExperimentalNodeDetailsDrawer from './ExperimentalNodeDetailsDrawer.vue';
|
||||||
import { nextTick } from 'vue';
|
import { nextTick } from 'vue';
|
||||||
|
import { fireEvent } from '@testing-library/vue';
|
||||||
|
|
||||||
const renderComponent = createComponentRenderer(ExperimentalNodeDetailsDrawer);
|
const renderComponent = createComponentRenderer(ExperimentalNodeDetailsDrawer);
|
||||||
|
|
||||||
@@ -62,7 +63,7 @@ describe('ExperimentalNodeDetailsDrawer', () => {
|
|||||||
pinia,
|
pinia,
|
||||||
props: {
|
props: {
|
||||||
node: mockNodes[0],
|
node: mockNodes[0],
|
||||||
nodes: [mockNodes[0]],
|
nodeIds: ['node1'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -77,4 +78,42 @@ describe('ExperimentalNodeDetailsDrawer', () => {
|
|||||||
|
|
||||||
await rendered.findByDisplayValue('after update');
|
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">
|
<script setup lang="ts">
|
||||||
import { useExpressionResolveCtx } from '@/components/canvas/experimental/composables/useExpressionResolveCtx';
|
import { useExpressionResolveCtx } from '@/components/canvas/experimental/composables/useExpressionResolveCtx';
|
||||||
|
import { type ContextMenuAction, useContextMenuItems } from '@/composables/useContextMenuItems';
|
||||||
import { ExpressionLocalResolveContextSymbol } from '@/constants';
|
import { ExpressionLocalResolveContextSymbol } from '@/constants';
|
||||||
import { type INodeUi } from '@/Interface';
|
import { type INodeUi } from '@/Interface';
|
||||||
import { N8nText } from '@n8n/design-system';
|
import { N8nButton, N8nKeyboardShortcut, N8nText } from '@n8n/design-system';
|
||||||
import { type GraphNode } from '@vue-flow/core';
|
|
||||||
import { computed, provide, ref, watch } from 'vue';
|
import { computed, provide, ref, watch } from 'vue';
|
||||||
import ExperimentalCanvasNodeSettings from './ExperimentalCanvasNodeSettings.vue';
|
import ExperimentalCanvasNodeSettings from './ExperimentalCanvasNodeSettings.vue';
|
||||||
import { useNDVStore } from '@/stores/ndv.store';
|
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 expressionResolveCtx = useExpressionResolveCtx(computed(() => node));
|
||||||
|
const contextMenuItems = useContextMenuItems(computed(() => nodeIds));
|
||||||
const ndvStore = useNDVStore();
|
const ndvStore = useNDVStore();
|
||||||
|
|
||||||
const ndvCloseTimes = ref(0);
|
const ndvCloseTimes = ref(0);
|
||||||
@@ -35,7 +39,21 @@ provide(ExpressionLocalResolveContextSymbol, expressionResolveCtx);
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :class="$style.component">
|
<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">
|
<ExperimentalCanvasNodeSettings v-else-if="node" :key="nodeSettingsViewKey" :node-id="node.id">
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<N8nIconButton
|
<N8nIconButton
|
||||||
@@ -54,10 +72,54 @@ provide(ExpressionLocalResolveContextSymbol, expressionResolveCtx);
|
|||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
.component {
|
.component {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: auto;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,17 +1,8 @@
|
|||||||
import type { ActionDropdownItem, XYPosition, INodeUi } from '@/Interface';
|
import type { XYPosition } 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 { 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 { computed, ref, watch } from 'vue';
|
||||||
import { getMousePosition } from '../utils/nodeViewUtils';
|
import { getMousePosition } from '../utils/nodeViewUtils';
|
||||||
import { useI18n } from '@n8n/i18n';
|
import { useContextMenuItems, type ContextMenuAction } from './useContextMenuItems';
|
||||||
import { usePinnedData } from './usePinnedData';
|
|
||||||
import { isPresent } from '../utils/typesUtils';
|
|
||||||
import { getResourcePermissions } from '@n8n/permissions';
|
|
||||||
|
|
||||||
export type ContextMenuTarget =
|
export type ContextMenuTarget =
|
||||||
| { source: 'canvas'; nodeIds: string[]; nodeId?: string }
|
| { source: 'canvas'; nodeIds: string[]; nodeId?: string }
|
||||||
@@ -19,105 +10,25 @@ export type ContextMenuTarget =
|
|||||||
| { source: 'node-button'; nodeId: string };
|
| { source: 'node-button'; nodeId: string };
|
||||||
export type ContextMenuActionCallback = (action: ContextMenuAction, nodeIds: string[]) => void;
|
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 position = ref<XYPosition>([0, 0]);
|
||||||
const isOpen = ref(false);
|
|
||||||
const target = ref<ContextMenuTarget>();
|
const target = ref<ContextMenuTarget>();
|
||||||
const actions = ref<ActionDropdownItem[]>([]);
|
|
||||||
const actionCallback = ref<ContextMenuActionCallback>(() => {});
|
|
||||||
|
|
||||||
export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) => {
|
export const useContextMenu = () => {
|
||||||
const uiStore = useUIStore();
|
const uiStore = useUIStore();
|
||||||
const nodeTypesStore = useNodeTypesStore();
|
const isOpen = computed(() => target.value !== undefined);
|
||||||
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 targetNodeIds = computed(() => {
|
const targetNodeIds = computed(() => {
|
||||||
if (!isOpen.value || !target.value) return [];
|
if (!target.value) return [];
|
||||||
|
|
||||||
const currentTarget = target.value;
|
const currentTarget = target.value;
|
||||||
return currentTarget.source === 'canvas' ? currentTarget.nodeIds : [currentTarget.nodeId];
|
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 = () => {
|
const close = () => {
|
||||||
target.value = undefined;
|
target.value = undefined;
|
||||||
isOpen.value = false;
|
|
||||||
actions.value = [];
|
|
||||||
position.value = [0, 0];
|
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) => {
|
const open = (event: MouseEvent, menuTarget: ContextMenuTarget) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
@@ -133,186 +44,21 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
|
|||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
actionCallback.value = onAction;
|
|
||||||
target.value = menuTarget;
|
target.value = menuTarget;
|
||||||
position.value = getMousePosition(event);
|
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) => {
|
const actions = useContextMenuItems(targetNodeIds);
|
||||||
actionCallback.value(a, targetNodeIds.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(
|
watch(() => uiStore.nodeViewOffsetPosition, close);
|
||||||
() => uiStore.nodeViewOffsetPosition,
|
|
||||||
() => {
|
|
||||||
close();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isOpen,
|
isOpen,
|
||||||
position,
|
position,
|
||||||
target,
|
target,
|
||||||
actions,
|
actions: computed(() => (isOpen.value ? actions.value : [])),
|
||||||
targetNodeIds,
|
targetNodeIds,
|
||||||
open,
|
open,
|
||||||
close,
|
close,
|
||||||
_dispatchAction,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -20,14 +20,14 @@ const i18n = useI18n();
|
|||||||
|
|
||||||
const isHovered = ref(false);
|
const isHovered = ref(false);
|
||||||
const isDropdownOpen = ref(false);
|
const isDropdownOpen = ref(false);
|
||||||
const dropdownRef = ref<InstanceType<typeof N8nActionDropdown>>();
|
const dropdownRef = ref<{ $el?: Node }>();
|
||||||
|
|
||||||
const enum ItemAction {
|
const enum ItemAction {
|
||||||
Delete = 'delete',
|
Delete = 'delete',
|
||||||
}
|
}
|
||||||
|
|
||||||
const onItemClick = (action: string) => {
|
const onItemClick = (action: ItemAction) => {
|
||||||
if (action === (ItemAction.Delete as string)) {
|
if (action === ItemAction.Delete) {
|
||||||
props.params.onDelete(props.params.column.getColId());
|
props.params.onDelete(props.params.column.getColId());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
watch,
|
watch,
|
||||||
h,
|
h,
|
||||||
onBeforeUnmount,
|
onBeforeUnmount,
|
||||||
|
useTemplateRef,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router';
|
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router';
|
||||||
import WorkflowCanvas from '@/components/canvas/WorkflowCanvas.vue';
|
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 { useAITemplatesStarterCollectionStore } from '@/experiments/aiTemplatesStarterCollection/stores/aiTemplatesStarterCollection.store';
|
||||||
import { useReadyToRunWorkflowsStore } from '@/experiments/readyToRunWorkflows/stores/readyToRunWorkflows.store';
|
import { useReadyToRunWorkflowsStore } from '@/experiments/readyToRunWorkflows/stores/readyToRunWorkflows.store';
|
||||||
import { useKeybindings } from '@/composables/useKeybindings';
|
import { useKeybindings } from '@/composables/useKeybindings';
|
||||||
|
import { type ContextMenuAction } from '@/composables/useContextMenuItems';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'NodeView',
|
name: 'NodeView',
|
||||||
@@ -263,6 +265,7 @@ useKeybindings({
|
|||||||
ctrl_alt_o: () => uiStore.openModal(ABOUT_MODAL_KEY),
|
ctrl_alt_o: () => uiStore.openModal(ABOUT_MODAL_KEY),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const canvasRef = useTemplateRef('canvas');
|
||||||
const isLoading = ref(true);
|
const isLoading = ref(true);
|
||||||
const isBlankRedirect = ref(false);
|
const isBlankRedirect = ref(false);
|
||||||
const readOnlyNotification = ref<null | { visible: boolean }>(null);
|
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() {
|
function addWorkflowSavedEventBindings() {
|
||||||
canvasEventBus.on('saved:workflow', npsSurveyStore.fetchPromptsData);
|
canvasEventBus.on('saved:workflow', npsSurveyStore.fetchPromptsData);
|
||||||
canvasEventBus.on('saved:workflow', onSaveFromWithinNDV);
|
canvasEventBus.on('saved:workflow', onSaveFromWithinNDV);
|
||||||
@@ -2043,6 +2050,7 @@ onBeforeUnmount(() => {
|
|||||||
<div :class="$style.wrapper">
|
<div :class="$style.wrapper">
|
||||||
<WorkflowCanvas
|
<WorkflowCanvas
|
||||||
v-if="editableWorkflow && editableWorkflowObject && !isLoading"
|
v-if="editableWorkflow && editableWorkflowObject && !isLoading"
|
||||||
|
ref="canvas"
|
||||||
:id="editableWorkflow.id"
|
:id="editableWorkflow.id"
|
||||||
:workflow="editableWorkflow"
|
:workflow="editableWorkflow"
|
||||||
:workflow-object="editableWorkflowObject"
|
:workflow-object="editableWorkflowObject"
|
||||||
@@ -2202,6 +2210,7 @@ onBeforeUnmount(() => {
|
|||||||
v-if="!isLoading"
|
v-if="!isLoading"
|
||||||
:is-canvas-read-only="isCanvasReadOnly"
|
:is-canvas-read-only="isCanvasReadOnly"
|
||||||
@save-keyboard-shortcut="onSaveWorkflow"
|
@save-keyboard-shortcut="onSaveWorkflow"
|
||||||
|
@context-menu-action="onContextMenuAction"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user