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 }),
|
||||
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" />',
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 actions = useContextMenuItems(targetNodeIds);
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => uiStore.nodeViewOffsetPosition,
|
||||
() => {
|
||||
close();
|
||||
},
|
||||
);
|
||||
watch(() => uiStore.nodeViewOffsetPosition, close);
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
position,
|
||||
target,
|
||||
actions,
|
||||
actions: computed(() => (isOpen.value ? actions.value : [])),
|
||||
targetNodeIds,
|
||||
open,
|
||||
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 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());
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user