mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(editor): Show restricted node types in the nodes panel and search (no-changelog) (#38865)
This commit is contained in:
parent
c3432699aa
commit
546aa598a0
|
|
@ -41,6 +41,14 @@ const sampleItems = [
|
|||
section: 'Actions',
|
||||
handler: () => console.log('Duplicating workflow'),
|
||||
},
|
||||
{
|
||||
id: 'publish-workflow',
|
||||
title: 'Publish workflow (no permission)',
|
||||
icon: { html: '🔒' },
|
||||
section: 'Actions',
|
||||
disabled: true,
|
||||
handler: () => console.log('Never runs: the item is disabled'),
|
||||
},
|
||||
|
||||
// Navigation section
|
||||
{
|
||||
|
|
|
|||
|
|
@ -148,6 +148,30 @@ describe('components', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('does not run a disabled item on click or Enter, and stays open', async () => {
|
||||
const onCreate = vi.fn();
|
||||
const items = createSampleItems().map((it) =>
|
||||
it.id === 'create' ? { ...it, handler: onCreate, disabled: true } : it,
|
||||
);
|
||||
|
||||
render(N8nCommandBar, { props: { items } });
|
||||
await openCommandBar();
|
||||
|
||||
const item = screen.getByText('Create new workflow');
|
||||
expect(item.closest('[aria-disabled="true"]')).not.toBeNull();
|
||||
|
||||
await fireEvent.click(item);
|
||||
expect(screen.getByPlaceholderText('Type a command...')).toBeInTheDocument();
|
||||
|
||||
// Filter so the disabled item is the highlighted one, then press Enter on it.
|
||||
const input = screen.getByPlaceholderText('Type a command...');
|
||||
await fireEvent.update(input, 'Create new');
|
||||
await fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
expect(onCreate).not.toHaveBeenCalled();
|
||||
expect(screen.getByPlaceholderText('Type a command...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes when clicking outside the command bar', async () => {
|
||||
render(N8nCommandBar, { props: { items: createSampleItems() } });
|
||||
await openCommandBar();
|
||||
|
|
|
|||
|
|
@ -187,6 +187,8 @@ const navigateBack = () => {
|
|||
};
|
||||
|
||||
const selectItem = (item: CommandBarItem) => {
|
||||
if (item.disabled) return;
|
||||
|
||||
if (item.children) {
|
||||
navigateToChildren(item);
|
||||
return;
|
||||
|
|
@ -234,7 +236,7 @@ const handleKeydown = (event: KeyboardEvent) => {
|
|||
case 'ArrowRight':
|
||||
if (selectedIndex.value >= 0 && flattenedItems.value[selectedIndex.value]) {
|
||||
const selectedItem = flattenedItems.value[selectedIndex.value];
|
||||
if (selectedItem.children) {
|
||||
if (selectedItem.children && !selectedItem.disabled) {
|
||||
event.preventDefault();
|
||||
void navigateToChildren(selectedItem);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,8 @@ const handleMouseLeave = () => {
|
|||
<div
|
||||
:key="item.id"
|
||||
:data-item-id="item.id"
|
||||
:class="[$style.item, { [$style.selected]: isSelected }]"
|
||||
:class="[$style.item, { [$style.selected]: isSelected, [$style.disabled]: item.disabled }]"
|
||||
:aria-disabled="item.disabled || undefined"
|
||||
@click.stop="handleSelect"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
|
|
@ -79,6 +80,15 @@ const handleMouseLeave = () => {
|
|||
&.selected {
|
||||
background-color: var(--command-bar-item--color--background--hover);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
|
||||
.icon,
|
||||
.content {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
|
|
|
|||
|
|
@ -11,4 +11,5 @@ export interface CommandBarItem {
|
|||
placeholder?: string;
|
||||
hasMoreChildren?: boolean;
|
||||
matchAnySearchTerm?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { StoryFn } from '@storybook/vue3-vite';
|
||||
|
||||
import N8nIcon from '../N8nIcon/Icon.vue';
|
||||
import N8nNodeCreatorNode from './NodeCreatorNode.vue';
|
||||
|
||||
export default {
|
||||
|
|
@ -19,6 +20,7 @@ const DefaultTemplate: StoryFn = (args, { argTypes }) => ({
|
|||
setup: () => ({ args }),
|
||||
props: Object.keys(argTypes),
|
||||
components: {
|
||||
N8nIcon,
|
||||
N8nNodeCreatorNode,
|
||||
},
|
||||
template: `
|
||||
|
|
@ -26,6 +28,9 @@ const DefaultTemplate: StoryFn = (args, { argTypes }) => ({
|
|||
<template #icon>
|
||||
<img src="https://dev.w3.org/SVG/tools/svgweb/samples/svg-files/cartman.svg" />
|
||||
</template>
|
||||
<template v-if="args.disabled" #trailing>
|
||||
<n8n-icon icon="lock" size="small" title="Restricted" />
|
||||
</template>
|
||||
</n8n-node-creator-node>
|
||||
`,
|
||||
});
|
||||
|
|
@ -66,3 +71,18 @@ WithPanel.args = {
|
|||
title: 'Node with panel',
|
||||
isTrigger: true,
|
||||
};
|
||||
|
||||
export const Restricted = DefaultTemplate.bind({});
|
||||
Restricted.args = {
|
||||
title: 'Gmail',
|
||||
description: 'Fetches emails from Gmail and starts the workflow on specified polling intervals.',
|
||||
disabled: true,
|
||||
};
|
||||
Restricted.parameters = {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'A node type a policy blocks. `disabled` fades the icon and text; the `trailing` slot keeps the lock at full strength. Rendered in the nodes panel by `NodeItem.vue`, which also anchors the explanation popover to the row.',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -26,4 +26,26 @@ describe('N8nNodeCreatorNode', () => {
|
|||
expect(className).not.toContain('info');
|
||||
expect(className).not.toContain('danger');
|
||||
});
|
||||
|
||||
it('greys the row and renders the trailing slot when disabled', () => {
|
||||
const { container, getByText } = render(NodeCreatorNode, {
|
||||
props: { title: 'Gmail', description: 'Send email', disabled: true },
|
||||
slots: { trailing: '<span data-test-id="trailing">lock</span>' },
|
||||
global: { stubs: ['N8nIcon'] },
|
||||
});
|
||||
|
||||
expect(container.firstElementChild?.className).toContain('disabled');
|
||||
expect(getByText('lock')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the action arrow instead of the trailing slot when the row has actions', () => {
|
||||
const { container, queryByText } = render(NodeCreatorNode, {
|
||||
props: { title: 'Gmail', showActionArrow: true },
|
||||
slots: { trailing: '<span>lock</span>' },
|
||||
global: { stubs: ['N8nIcon'] },
|
||||
});
|
||||
|
||||
expect(container.querySelector('button')).toBeInTheDocument();
|
||||
expect(queryByText('lock')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export interface Props {
|
|||
isOfficial?: boolean;
|
||||
hideNodeIcon?: boolean;
|
||||
isNew?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
|
|
@ -27,7 +28,7 @@ defineEmits<{
|
|||
tooltipClick: [e: MouseEvent];
|
||||
}>();
|
||||
|
||||
defineSlots<{ icon: {}; extraDetails: {}; dragContent: {} }>();
|
||||
defineSlots<{ icon: {}; extraDetails: {}; dragContent: {}; trailing: {} }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
|
@ -37,13 +38,14 @@ const { t } = useI18n();
|
|||
:class="{
|
||||
[$style.creatorNode]: true,
|
||||
[$style.hasAction]: !showActionArrow,
|
||||
[$style.disabled]: disabled,
|
||||
}"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<div v-if="!hideNodeIcon" :class="$style.nodeIcon">
|
||||
<slot name="icon" />
|
||||
</div>
|
||||
<div>
|
||||
<div :class="$style.body">
|
||||
<div :class="$style.details">
|
||||
<span :class="$style.name" data-test-id="node-creator-item-name" v-text="title" />
|
||||
<PreviewTag v-if="tag?.preview" size="small" :class="$style.previewTag" :text="tag.text" />
|
||||
|
|
@ -85,6 +87,9 @@ const { t } = useI18n();
|
|||
<button v-if="showActionArrow" :class="$style.panelIcon">
|
||||
<N8nIcon icon="arrow-right" size="large" />
|
||||
</button>
|
||||
<div v-else-if="$slots.trailing" :class="$style.trailing">
|
||||
<slot name="trailing" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -103,6 +108,24 @@ const { t } = useI18n();
|
|||
.creatorNode:hover .panelIcon {
|
||||
color: var(--action--arrow--color--hover, var(--color--text--tint-1));
|
||||
}
|
||||
.disabled {
|
||||
cursor: not-allowed;
|
||||
|
||||
// Fade the node, not the trailing slot, so a status icon there keeps full strength.
|
||||
.nodeIcon,
|
||||
.body {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
.trailing {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
margin-left: var(--spacing--2xs);
|
||||
padding-right: var(--spacing--2xs);
|
||||
color: var(--color--text--tint-1);
|
||||
}
|
||||
.previewTag {
|
||||
margin-left: var(--spacing--2xs);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1,4 @@
|
|||
export const TOOLTIP_DELAY_MS = 500;
|
||||
|
||||
/** reka-ui stamps this on every popover and dialog layer it renders (N8nPopover, N8nDialog). */
|
||||
export const OVERLAY_LAYER_SELECTOR = '[data-dismissable-layer]';
|
||||
|
|
|
|||
|
|
@ -2436,6 +2436,9 @@
|
|||
"typeAvailabilityPolicies.restrictedNode.contactAdmin": "Contact instance admin",
|
||||
"typeAvailabilityPolicies.restrictedNode.contactAdmin.description": "Instance admins manage which nodes are available. Contact one of them to request access to '{nodeType}'.",
|
||||
"typeAvailabilityPolicies.restrictedNode.contactAdmin.mailSubject": "Access request for the {nodeType} node",
|
||||
"typeAvailabilityPolicies.restrictedNode.scope.instance": "Restricted on this instance",
|
||||
"typeAvailabilityPolicies.restrictedNode.scope.project": "Restricted in this project",
|
||||
"typeAvailabilityPolicies.restrictedNode.popover.description": "An administrator blocked this node. To use it in your workflows, contact an instance administrator for access.",
|
||||
"typeAvailabilityPolicies.contactAdmin.title": "Instance admins",
|
||||
"typeAvailabilityPolicies.contactAdmin.empty": "No instance admin is listed here. Ask the person who manages this n8n instance.",
|
||||
"nodeView.addNode": "Add node",
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
import { deepCopy, type ICredentialType, type INodeTypeDescription } from 'n8n-workflow';
|
||||
import { CREDENTIAL_ONLY_NODE_PREFIX } from '../constants';
|
||||
import { CREDENTIAL_ONLY_NODE_PREFIX, HTTP_REQUEST_NODE_TYPE } from '../constants';
|
||||
import { i18n } from '@n8n/i18n';
|
||||
|
||||
export function isCredentialOnlyNodeType(nodeTypeName: string): boolean {
|
||||
return nodeTypeName?.startsWith(CREDENTIAL_ONLY_NODE_PREFIX) ?? false;
|
||||
}
|
||||
|
||||
/** A credential-only node is HTTP Request in disguise, so policy applies to the node it wraps. */
|
||||
export function toPolicyNodeType(nodeTypeName: string): string {
|
||||
return isCredentialOnlyNodeType(nodeTypeName) ? HTTP_REQUEST_NODE_TYPE : nodeTypeName;
|
||||
}
|
||||
|
||||
export function getCredentialTypeName(nodeTypeName: string): string {
|
||||
return nodeTypeName.split('.')[1];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts" setup>
|
||||
import type { IconOrEmoji, KeyboardShortcut } from '@n8n/design-system';
|
||||
import { N8nKeyboardShortcut } from '@n8n/design-system';
|
||||
import type { IconName, IconOrEmoji, KeyboardShortcut } from '@n8n/design-system';
|
||||
import { N8nIcon, N8nKeyboardShortcut } from '@n8n/design-system';
|
||||
import ProjectIcon from '@/features/collaboration/projects/components/ProjectIcon.vue';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -9,6 +9,8 @@ interface Props {
|
|||
suffixIcon?: IconOrEmoji;
|
||||
actionText?: string;
|
||||
shortcut?: KeyboardShortcut;
|
||||
/** A trailing status icon, such as a lock for an entry the user cannot run. */
|
||||
icon?: IconName;
|
||||
|
||||
// passed from CommandBarItem
|
||||
isSelected?: boolean;
|
||||
|
|
@ -28,6 +30,7 @@ defineProps<Props>();
|
|||
<span v-if="actionText && (isSelected || isHovered)" :class="$style.action">{{
|
||||
actionText
|
||||
}}</span>
|
||||
<N8nIcon v-if="icon" :icon="icon" size="small" :class="$style.icon" />
|
||||
<span v-if="shortcut" :class="$style.shortcut">
|
||||
<N8nKeyboardShortcut
|
||||
:keys="shortcut.keys"
|
||||
|
|
@ -77,4 +80,8 @@ defineProps<Props>();
|
|||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.icon {
|
||||
color: var(--color--text--tint-1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { ref } from 'vue';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useNodeCommands } from './useNodeCommands';
|
||||
import { mockRestrictedNodeTypes } from '@/features/shared/nodeCreator/__tests__/utils';
|
||||
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
|
||||
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
|
||||
import { useWorkflowsStore } from '@/app/stores/workflows.store';
|
||||
|
|
@ -222,6 +223,73 @@ describe('useNodeCommands', () => {
|
|||
|
||||
expect(mockGenerateMergedNodesAndActionsFn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should list a restricted node last, disabled and locked, instead of hiding it', () => {
|
||||
mockGenerateMergedNodesAndActionsFn.mockReturnValue({
|
||||
mergedNodes: [
|
||||
createMockNodeType('n8n-nodes-base.gmail', 'Gmail'),
|
||||
createMockNodeType('n8n-nodes-base.slack', 'Slack'),
|
||||
],
|
||||
});
|
||||
mockRestrictedNodeTypes({ 'n8n-nodes-base.gmail': 'instance' });
|
||||
|
||||
const { commands } = useNodeCommands({
|
||||
lastQuery: ref(''),
|
||||
activeNodeId: ref(null),
|
||||
});
|
||||
|
||||
const children = commands.value.find((cmd) => cmd.id === 'add-node')?.children ?? [];
|
||||
expect(children.map((child) => child.id)).toEqual([
|
||||
'n8n-nodes-base.slack',
|
||||
'n8n-nodes-base.gmail',
|
||||
]);
|
||||
expect(children[0].disabled).toBe(false);
|
||||
expect(children[1].disabled).toBe(true);
|
||||
const restrictedTitle = children[1].title;
|
||||
expect(typeof restrictedTitle === 'object' && restrictedTitle.props?.icon).toBe('lock');
|
||||
});
|
||||
|
||||
it('should disable a credential-only node when HTTP Request is restricted', () => {
|
||||
mockGenerateMergedNodesAndActionsFn.mockReturnValue({
|
||||
mergedNodes: [
|
||||
createMockNodeType('n8n-creds-base.sysdigApi', 'Sysdig'),
|
||||
createMockNodeType('n8n-nodes-base.slack', 'Slack'),
|
||||
],
|
||||
});
|
||||
mockRestrictedNodeTypes({ 'n8n-nodes-base.httpRequest': 'instance' });
|
||||
|
||||
const { commands } = useNodeCommands({
|
||||
lastQuery: ref(''),
|
||||
activeNodeId: ref(null),
|
||||
});
|
||||
|
||||
const children = commands.value.find((cmd) => cmd.id === 'add-node')?.children ?? [];
|
||||
expect(children.map((child) => [child.id, child.disabled ?? false])).toEqual([
|
||||
['n8n-nodes-base.slack', false],
|
||||
['n8n-creds-base.sysdigApi', true],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should disable a node type the policy restricts', () => {
|
||||
mockGenerateMergedNodesAndActionsFn.mockReturnValue({
|
||||
mergedNodes: [
|
||||
createMockNodeType('n8n-nodes-base.httpRequest', 'HTTP Request'),
|
||||
createMockNodeType('n8n-nodes-base.slack', 'Slack'),
|
||||
],
|
||||
});
|
||||
mockRestrictedNodeTypes({ 'n8n-nodes-base.slack': 'instance' });
|
||||
|
||||
const { commands } = useNodeCommands({
|
||||
lastQuery: ref(''),
|
||||
activeNodeId: ref(null),
|
||||
});
|
||||
|
||||
const children = commands.value.find((cmd) => cmd.id === 'add-node')?.children ?? [];
|
||||
expect(children.map((child) => [child.id, child.disabled ?? false])).toEqual([
|
||||
['n8n-nodes-base.httpRequest', false],
|
||||
['n8n-nodes-base.slack', true],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('open node command', () => {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
|
|||
import { useCredentialsStore } from '@/features/credentials/credentials.store';
|
||||
import { useCanvasOperations } from '@/app/composables/useCanvasOperations';
|
||||
import { useActionsGenerator } from '@/features/shared/nodeCreator/composables/useActionsGeneration';
|
||||
import { isNodeItemRestricted } from '@/features/shared/nodeCreator/nodeCreator.utils';
|
||||
import { canvasEventBus } from '@/features/workflows/canvas/canvas.eventBus';
|
||||
import { type CommandBarItem } from '@n8n/design-system';
|
||||
import type { CommandGroup } from '../types';
|
||||
|
|
@ -53,24 +54,32 @@ export function useNodeCommands(options: {
|
|||
(workflowPermissions.value[permission] === true && !isReadOnly.value && !isArchived.value) ||
|
||||
!workflowsStore.isWorkflowSaved[workflowsStore.workflowId];
|
||||
|
||||
// Restricted types stay listed, like in the nodes panel: greyed, locked, last, and inert.
|
||||
const mergedNodes = computed(() => {
|
||||
const httpOnlyCredentials = credentialsStore.httpOnlyCredentialTypes;
|
||||
const nodeTypes = nodeTypesStore.visibleNodeTypes;
|
||||
return generateMergedNodesAndActions(nodeTypes, httpOnlyCredentials).mergedNodes;
|
||||
const nodes = generateMergedNodesAndActions(nodeTypes, httpOnlyCredentials).mergedNodes;
|
||||
const restricted = new Set(nodes.filter((node) => isNodeItemRestricted(node.name)));
|
||||
return [...nodes.filter((node) => !restricted.has(node)), ...restricted];
|
||||
});
|
||||
|
||||
const buildAddNodeCommand = (node: SimplifiedNodeType, isRoot: boolean): CommandBarItem => {
|
||||
const { name, displayName } = node;
|
||||
|
||||
const title = isRoot ? `${i18n.baseText('generic.add')} ${displayName}` : displayName;
|
||||
const titleText = isRoot ? `${i18n.baseText('generic.add')} ${displayName}` : displayName;
|
||||
const section = isRoot
|
||||
? i18n.baseText('commandBar.sections.nodes')
|
||||
: i18n.baseText('commandBar.nodes.addNode');
|
||||
const disabled = isNodeItemRestricted(name);
|
||||
const title = disabled
|
||||
? { component: CommandBarItemTitle, props: { title: titleText, icon: 'lock' } }
|
||||
: titleText;
|
||||
|
||||
return {
|
||||
id: name,
|
||||
title,
|
||||
section,
|
||||
disabled,
|
||||
keywords: [displayName],
|
||||
icon: {
|
||||
component: NodeIcon as Component,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import type { NodeTypeAvailabilityScope } from '@n8n/api-types';
|
||||
import { useTypeAvailabilityPoliciesStore } from '@n8n/frontend-module-type-availability-policies';
|
||||
import { vi } from 'vitest';
|
||||
import type {
|
||||
SimplifiedNodeType,
|
||||
ActionTypeDescription,
|
||||
|
|
@ -13,6 +16,21 @@ import type {
|
|||
} from '@/Interface';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
/**
|
||||
* Makes the active pinia's policy store report these node types as restricted. Call after
|
||||
* `setActivePinia` (or after rendering with the pinia the component uses).
|
||||
*/
|
||||
export function mockRestrictedNodeTypes(
|
||||
restricted: Record<string, NodeTypeAvailabilityScope> = {},
|
||||
): void {
|
||||
vi.spyOn(useTypeAvailabilityPoliciesStore(), 'getNodeTypeAvailability').mockImplementation(
|
||||
(name) => {
|
||||
const scope = restricted[name];
|
||||
return scope ? { name, available: false, scope } : { name, available: true };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export const mockSimplifiedNodeType = (
|
||||
overrides?: Partial<SimplifiedNodeType>,
|
||||
): SimplifiedNodeType => ({
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import type { Pinia } from 'pinia';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { screen } from '@testing-library/vue';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import {
|
||||
AI_CATEGORY_OTHER_TOOLS,
|
||||
|
|
@ -8,7 +10,7 @@ import {
|
|||
MESSAGE_AN_AGENT_NODE_TYPE,
|
||||
} from '@/app/constants';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { mockSimplifiedNodeType } from '../../__tests__/utils';
|
||||
import { mockRestrictedNodeTypes, mockSimplifiedNodeType } from '../../__tests__/utils';
|
||||
import { useViewStacks } from '../../composables/useViewStacks';
|
||||
import NodeItem from './NodeItem.vue';
|
||||
import type { AddedNodesAndConnections } from '@/Interface';
|
||||
|
|
@ -213,4 +215,71 @@ describe('NodeItem', () => {
|
|||
expect(getDescription(container)).toBe('Interact with Anthropic AI models');
|
||||
});
|
||||
});
|
||||
|
||||
describe('restricted node type', () => {
|
||||
const gmail = () =>
|
||||
mockSimplifiedNodeType({
|
||||
name: 'n8n-nodes-base.gmail',
|
||||
displayName: 'Gmail',
|
||||
group: ['output'],
|
||||
});
|
||||
|
||||
it('is greyed, locked, not draggable and has no action arrow', () => {
|
||||
mockRestrictedNodeTypes({ 'n8n-nodes-base.gmail': 'instance' });
|
||||
|
||||
const { container, getByTestId } = render({ pinia, props: { nodeType: gmail() } });
|
||||
|
||||
const row = getByTestId('node-creator-restricted-item');
|
||||
expect(row.getAttribute('draggable')).toBe('false');
|
||||
expect(row.className).toContain('disabled');
|
||||
expect(getByTestId('node-restricted-icon')).toBeInTheDocument();
|
||||
expect(container.querySelector('[data-icon="arrow-right"]')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the explanation for the hovered row, anchored to the row', async () => {
|
||||
mockRestrictedNodeTypes({ 'n8n-nodes-base.gmail': 'instance' });
|
||||
|
||||
const { getByTestId } = render({ pinia, props: { nodeType: gmail() } });
|
||||
expect(screen.queryByTestId('node-restricted-popover')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.hover(getByTestId('node-creator-restricted-item'));
|
||||
|
||||
expect(await screen.findByText('Restricted on this instance')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the explanation for the keyboard-active row with its scope', async () => {
|
||||
mockRestrictedNodeTypes({ 'n8n-nodes-base.gmail': 'project' });
|
||||
|
||||
render({ pinia, props: { nodeType: gmail(), active: true } });
|
||||
|
||||
expect(await screen.findByText('Restricted in this project')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('treats a credential-only node like the HTTP Request node it wraps', () => {
|
||||
mockRestrictedNodeTypes({ 'n8n-nodes-base.httpRequest': 'instance' });
|
||||
|
||||
const { queryByTestId } = render({
|
||||
pinia,
|
||||
props: {
|
||||
nodeType: mockSimplifiedNodeType({
|
||||
name: 'n8n-creds-base.sysdigApi',
|
||||
displayName: 'Sysdig',
|
||||
group: ['output'],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(queryByTestId('node-creator-restricted-item')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a normal row when the type is not restricted', () => {
|
||||
mockRestrictedNodeTypes();
|
||||
|
||||
const { container, queryByTestId } = render({ pinia, props: { nodeType: gmail() } });
|
||||
|
||||
expect(queryByTestId('node-creator-restricted-item')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('node-restricted-icon')).not.toBeInTheDocument();
|
||||
expect(container.querySelector('[draggable="true"]')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
MESSAGE_AN_AGENT_NODE_TYPE,
|
||||
} from '@/app/constants';
|
||||
import { COMMUNITY_NODES_INSTALLATION_DOCS_URL } from '@/features/settings/communityNodes/communityNodes.constants';
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, ref, type ComponentPublicInstance } from 'vue';
|
||||
|
||||
import NodeIcon from '@/app/components/NodeIcon.vue';
|
||||
import { getNodeIconSize } from '@/app/utils/nodeIcon';
|
||||
|
|
@ -24,12 +24,14 @@ import { useI18n } from '@n8n/i18n';
|
|||
import { useActions } from '../../composables/useActions';
|
||||
import { useViewStacks } from '../../composables/useViewStacks';
|
||||
import {
|
||||
getNodeItemRestriction,
|
||||
isNodePreviewKey,
|
||||
removePreviewToken,
|
||||
shouldShowCommunityNodeDetails,
|
||||
} from '../../nodeCreator.utils';
|
||||
|
||||
import { N8nIcon, N8nNodeCreatorNode, N8nTooltip } from '@n8n/design-system';
|
||||
import { RestrictedNodePopover } from '@n8n/frontend-module-type-availability-policies';
|
||||
export interface Props {
|
||||
nodeType: SimplifiedNodeType;
|
||||
subcategory?: string;
|
||||
|
|
@ -51,6 +53,8 @@ const { isSubNodeType } = useNodeType({
|
|||
nodeType: props.nodeType,
|
||||
});
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const restriction = computed(() => getNodeItemRestriction(props.nodeType.name));
|
||||
const rowRef = ref<ComponentPublicInstance | null>(null);
|
||||
|
||||
const dragging = ref(false);
|
||||
const draggablePosition = ref({ x: -100, y: -100 });
|
||||
|
|
@ -78,6 +82,8 @@ const description = computed<string>(() => {
|
|||
});
|
||||
|
||||
const showActionArrow = computed(() => {
|
||||
if (restriction.value) return false;
|
||||
|
||||
if (shouldShowCommunityNodeDetails(isCommunityNode.value, activeViewStack)) {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -97,9 +103,10 @@ const isSendAndWaitCategory = computed(
|
|||
activeViewStack.subcategory === HITL_SUBCATEGORY ||
|
||||
activeViewStack.rootView === HUMAN_IN_THE_LOOP_CATEGORY,
|
||||
);
|
||||
const dataTestId = computed(() =>
|
||||
hasActions.value ? 'node-creator-action-item' : 'node-creator-node-item',
|
||||
);
|
||||
const dataTestId = computed(() => {
|
||||
if (restriction.value) return 'node-creator-restricted-item';
|
||||
return hasActions.value ? 'node-creator-action-item' : 'node-creator-node-item';
|
||||
});
|
||||
|
||||
const hasActions = computed(() => {
|
||||
return nodeActions.value.length > 1 && !activeViewStack.hideActions;
|
||||
|
|
@ -195,7 +202,9 @@ function onCommunityNodeTooltipClick(event: MouseEvent) {
|
|||
<template>
|
||||
<!-- Node Item is draggable only if it doesn't contain actions -->
|
||||
<N8nNodeCreatorNode
|
||||
:draggable="!showActionArrow"
|
||||
ref="rowRef"
|
||||
:draggable="!restriction && !showActionArrow"
|
||||
:disabled="!!restriction"
|
||||
:class="$style.nodeItem"
|
||||
:description="description"
|
||||
:title="displayName"
|
||||
|
|
@ -253,6 +262,14 @@ function onCommunityNodeTooltipClick(event: MouseEvent) {
|
|||
<N8nIcon size="small" :class="$style.icon" icon="box" />
|
||||
</N8nTooltip>
|
||||
</template>
|
||||
<template v-if="restriction" #trailing>
|
||||
<RestrictedNodePopover
|
||||
:node-type-name="displayName"
|
||||
:scope="restriction.scope"
|
||||
:anchor="rowRef"
|
||||
:active="active"
|
||||
/>
|
||||
</template>
|
||||
<template #dragContent>
|
||||
<div
|
||||
v-show="dragging"
|
||||
|
|
|
|||
|
|
@ -8,15 +8,19 @@ import {
|
|||
AI_CATEGORY_MCP_NODES,
|
||||
AI_MCP_TOOL_NODE_TYPE,
|
||||
AI_OTHERS_NODE_CREATOR_VIEW,
|
||||
HTTP_REQUEST_NODE_TYPE,
|
||||
MESSAGE_AN_AGENT_NODE_TYPE,
|
||||
REGULAR_NODE_CREATOR_VIEW,
|
||||
REQUEST_NODE_FORM_URL,
|
||||
SUGGEST_SERVICE_FORM_URL_REMOTE_CONFIG_KEY,
|
||||
TRIGGER_NODE_CREATOR_VIEW,
|
||||
} from '@/app/constants';
|
||||
import type { NodeCreateElement } from '@/Interface';
|
||||
import { useViewStacks } from '@/features/shared/nodeCreator/composables/useViewStacks';
|
||||
import { useKeyboardNavigation } from '@/features/shared/nodeCreator/composables/useKeyboardNavigation';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { mockSimplifiedNodeType } from '../../__tests__/utils';
|
||||
import { waitAllPromises } from '@n8n/frontend-test-utils';
|
||||
import { mockRestrictedNodeTypes, mockSimplifiedNodeType } from '../../__tests__/utils';
|
||||
import NodesMode from './NodesMode.vue';
|
||||
|
||||
const mockDocumentStoreState = {
|
||||
|
|
@ -88,6 +92,10 @@ describe('NodesMode', () => {
|
|||
setActivePinia(pinia);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useKeyboardNavigation().detachKeydownEvent();
|
||||
});
|
||||
|
||||
it('opens the agent picker sub-panel instead of adding the Message an Agent node', async () => {
|
||||
useViewStacks().pushViewStack({
|
||||
title: 'What happens next?',
|
||||
|
|
@ -139,6 +147,98 @@ describe('NodesMode', () => {
|
|||
expect(emitted('nodeTypeSelected')).toEqual([[[{ type: 'n8n-nodes-base.set' }]]]);
|
||||
});
|
||||
|
||||
describe('restricted node types', () => {
|
||||
function setNodeElement(): NodeCreateElement {
|
||||
return {
|
||||
key: 'n8n-nodes-base.set',
|
||||
type: 'node',
|
||||
subcategory: '*',
|
||||
properties: mockSimplifiedNodeType({
|
||||
name: 'n8n-nodes-base.set',
|
||||
displayName: 'Edit Fields',
|
||||
group: ['transform'],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Browsing hides a restricted type, so the row is only there to click while searching.
|
||||
function pushSearchStackWith(items: NodeCreateElement[]) {
|
||||
useViewStacks().pushViewStack({
|
||||
title: 'What happens next?',
|
||||
mode: 'nodes',
|
||||
rootView: REGULAR_NODE_CREATOR_VIEW,
|
||||
hasSearch: true,
|
||||
search: 'Edit Fields',
|
||||
items,
|
||||
});
|
||||
}
|
||||
|
||||
async function pressEnterOnFirstItem() {
|
||||
const keyboardNavigation = useKeyboardNavigation();
|
||||
keyboardNavigation.attachKeydownEvent();
|
||||
await keyboardNavigation.setActiveItemIndex(0);
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
// Keyboard navigation refreshes its selectable items on a zero-delay timer.
|
||||
await waitAllPromises();
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
it('does not add a restricted node on click', async () => {
|
||||
mockRestrictedNodeTypes({ 'n8n-nodes-base.set': 'instance' });
|
||||
pushSearchStackWith([setNodeElement()]);
|
||||
|
||||
const { emitted } = render({ pinia });
|
||||
await nextTick();
|
||||
|
||||
await userEvent.click(screen.getByText('Edit Fields'));
|
||||
|
||||
expect(emitted('nodeTypeSelected')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not add a restricted node on Enter', async () => {
|
||||
mockRestrictedNodeTypes({ 'n8n-nodes-base.set': 'instance' });
|
||||
pushSearchStackWith([setNodeElement()]);
|
||||
|
||||
const { emitted } = render({ pinia });
|
||||
await nextTick();
|
||||
|
||||
await pressEnterOnFirstItem();
|
||||
|
||||
expect(emitted('nodeTypeSelected')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops a restricted node from the empty-search suggestions', async () => {
|
||||
mockRestrictedNodeTypes({ [HTTP_REQUEST_NODE_TYPE]: 'instance' });
|
||||
useViewStacks().pushViewStack({
|
||||
title: 'What triggers this workflow?',
|
||||
mode: 'nodes',
|
||||
rootView: TRIGGER_NODE_CREATOR_VIEW,
|
||||
search: 'missing node',
|
||||
items: [],
|
||||
});
|
||||
|
||||
const { emitted } = render({ pinia });
|
||||
await nextTick();
|
||||
|
||||
expect(screen.getByText('No results for "missing node"')).toBeInTheDocument();
|
||||
expect(screen.queryByText('HTTP Request')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText('Webhook'));
|
||||
expect(emitted('nodeTypeSelected')).toEqual([[[{ type: 'n8n-nodes-base.webhook' }]]]);
|
||||
});
|
||||
|
||||
it('still adds an available node on Enter', async () => {
|
||||
pushSearchStackWith([setNodeElement()]);
|
||||
|
||||
const { emitted } = render({ pinia });
|
||||
await nextTick();
|
||||
|
||||
await pressEnterOnFirstItem();
|
||||
|
||||
expect(emitted('nodeTypeSelected')).toEqual([[[{ type: 'n8n-nodes-base.set' }]]]);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the MCP client pinned once and shows the MCP empty state for no results', async () => {
|
||||
const mcpClient = mcpClientElement();
|
||||
const viewStacks = useViewStacks();
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ import {
|
|||
getRootSearchCallouts,
|
||||
shouldShowCommunityNodeDetails,
|
||||
getHumanInTheLoopActions,
|
||||
isNodeItemRestricted,
|
||||
sinkRestrictedNodesLast,
|
||||
} from '../../nodeCreator.utils';
|
||||
import { useViewStacks } from '../../composables/useViewStacks';
|
||||
import { useKeyboardNavigation } from '../../composables/useKeyboardNavigation';
|
||||
|
|
@ -80,7 +82,7 @@ const workflowDocumentStore = injectWorkflowDocumentStore();
|
|||
const communityNodesAndActions = computed(() => useNodeTypesStore().communityNodesAndActions);
|
||||
|
||||
const moreFromCommunity = computed(() => {
|
||||
return filterAndSearchNodes(
|
||||
const hits = filterAndSearchNodes(
|
||||
communityNodesAndActions.value.mergedNodes,
|
||||
activeViewStack.value.search ?? '',
|
||||
{
|
||||
|
|
@ -89,11 +91,15 @@ const moreFromCommunity = computed(() => {
|
|||
aiConnectionType: activeViewStack.value.connectionType,
|
||||
},
|
||||
);
|
||||
return sinkRestrictedNodesLast(hits, isNodeItemRestricted);
|
||||
});
|
||||
|
||||
const isSearchResultEmpty = computed(() => {
|
||||
// The pinned MCP client is a placeholder, not a result — unless it is restricted, in which
|
||||
// case it is an ordinary search hit.
|
||||
const hasNodeResults = (activeViewStack.value.items ?? []).some(
|
||||
(item) => !isMcpCategory.value || item.key !== AI_MCP_TOOL_NODE_TYPE,
|
||||
(item) =>
|
||||
!isMcpCategory.value || item.key !== AI_MCP_TOOL_NODE_TYPE || isNodeItemRestricted(item.key),
|
||||
);
|
||||
return (
|
||||
!hasNodeResults &&
|
||||
|
|
@ -120,6 +126,10 @@ function getFilteredActions(
|
|||
}
|
||||
|
||||
function onSelected(item: INodeCreateElement) {
|
||||
// Insertion itself is refused in getAddedNodesAndConnections; this keeps a restricted
|
||||
// node from opening its actions view.
|
||||
if (item.type === 'node' && isNodeItemRestricted(item.key)) return;
|
||||
|
||||
if (item.type === 'subcategory') {
|
||||
const subcategoryKey = camelCase(item.properties.title);
|
||||
const title = i18n.baseText(`nodeCreator.subcategoryNames.${subcategoryKey}` as BaseTextKey);
|
||||
|
|
@ -377,6 +387,8 @@ registerKeyHook('MainViewArrowLeft', {
|
|||
<NoResults
|
||||
:query="activeViewStack.search ?? ''"
|
||||
:root-view="activeViewStack.rootView"
|
||||
:suggest-webhook="!isNodeItemRestricted(WEBHOOK_NODE_TYPE)"
|
||||
:suggest-http-request="!isNodeItemRestricted(HTTP_REQUEST_NODE_TYPE)"
|
||||
@add-webhook-node="emit('nodeTypeSelected', [{ type: WEBHOOK_NODE_TYPE }])"
|
||||
@add-http-node="emit('nodeTypeSelected', [{ type: HTTP_REQUEST_NODE_TYPE }])"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createPinia } from 'pinia';
|
||||
import { onClickOutside } from '@vueuse/core';
|
||||
import { OVERLAY_LAYER_SELECTOR } from '@n8n/design-system';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import NodeCreator from './NodeCreator.vue';
|
||||
|
||||
|
|
@ -92,4 +94,14 @@ describe('NodeCreator', () => {
|
|||
|
||||
expect(mockFetchConfig).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not close on clicks inside Element Plus modals or reka overlays', () => {
|
||||
renderComponent();
|
||||
|
||||
expect(vi.mocked(onClickOutside)).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.any(Function),
|
||||
{ ignore: expect.arrayContaining(['.el-overlay-dialog', OVERLAY_LAYER_SELECTOR]) },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,11 +19,13 @@ import { useAiGateway } from '@/app/composables/useAiGateway';
|
|||
import type { NodeTypeSelectedPayload } from '@/Interface';
|
||||
import { onClickOutside } from '@vueuse/core';
|
||||
|
||||
import { N8nIconButton } from '@n8n/design-system';
|
||||
import { N8nIconButton, OVERLAY_LAYER_SELECTOR } from '@n8n/design-system';
|
||||
// elements that should not trigger onClickOutside
|
||||
const OUTSIDE_CLICK_WHITELIST = [
|
||||
// different modals
|
||||
'.el-overlay-dialog',
|
||||
// reka-ui overlays (N8nPopover, N8nDialog) teleport to body
|
||||
OVERLAY_LAYER_SELECTOR,
|
||||
];
|
||||
|
||||
export interface Props {
|
||||
|
|
|
|||
|
|
@ -64,6 +64,50 @@ describe('NoResults', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('keeps the Webhook suggestion when only HTTP Request is restricted', async () => {
|
||||
const wrapper = renderComponent({
|
||||
props: { query: 'Gmail MCP', rootView: TRIGGER_NODE_CREATOR_VIEW, suggestHttpRequest: false },
|
||||
});
|
||||
|
||||
expect(screen.queryByText('HTTP Request')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('node-creator-no-results')).toHaveTextContent(/our Webhook node/);
|
||||
|
||||
await fireEvent.click(screen.getByText('Webhook'));
|
||||
expect(wrapper.emitted('addWebhookNode')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps the HTTP Request suggestion when only Webhook is restricted', async () => {
|
||||
const wrapper = renderComponent({
|
||||
props: { query: 'Gmail MCP', rootView: TRIGGER_NODE_CREATOR_VIEW, suggestWebhook: false },
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Webhook')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('node-creator-no-results')).toHaveTextContent(
|
||||
/our HTTP Request node/,
|
||||
);
|
||||
|
||||
await fireEvent.click(screen.getByText('HTTP Request'));
|
||||
expect(wrapper.emitted('addHttpNode')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('hides the suggestion line when both nodes are restricted', () => {
|
||||
renderComponent({
|
||||
props: {
|
||||
query: 'Gmail MCP',
|
||||
rootView: TRIGGER_NODE_CREATOR_VIEW,
|
||||
suggestHttpRequest: false,
|
||||
suggestWebhook: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByText('No results for "Gmail MCP"')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Webhook')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('HTTP Request')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('node-creator-no-results')).not.toHaveTextContent(
|
||||
'Connect to almost any service',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not suggest incompatible nodes in specialized AI views', () => {
|
||||
renderComponent({
|
||||
props: { query: 'Gmail MCP', rootView: AI_OTHERS_NODE_CREATOR_VIEW },
|
||||
|
|
|
|||
|
|
@ -1,19 +1,34 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { N8nLink } from '@n8n/design-system';
|
||||
import { REGULAR_NODE_CREATOR_VIEW, TRIGGER_NODE_CREATOR_VIEW } from '@/app/constants';
|
||||
import type { NodeFilterType } from '@/Interface';
|
||||
|
||||
defineProps<{
|
||||
query: string;
|
||||
rootView?: NodeFilterType;
|
||||
}>();
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
query: string;
|
||||
rootView?: NodeFilterType;
|
||||
suggestHttpRequest?: boolean;
|
||||
suggestWebhook?: boolean;
|
||||
}>(),
|
||||
{ rootView: undefined, suggestHttpRequest: true, suggestWebhook: true },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
addHttpNode: [];
|
||||
addWebhookNode: [];
|
||||
}>();
|
||||
const i18n = useI18n();
|
||||
|
||||
const showWebhook = computed(
|
||||
() => props.suggestWebhook && props.rootView === TRIGGER_NODE_CREATOR_VIEW,
|
||||
);
|
||||
const showHttpRequest = computed(
|
||||
() =>
|
||||
props.suggestHttpRequest &&
|
||||
(props.rootView === REGULAR_NODE_CREATOR_VIEW || props.rootView === TRIGGER_NODE_CREATOR_VIEW),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -25,18 +40,23 @@ const i18n = useI18n();
|
|||
})
|
||||
}}
|
||||
</p>
|
||||
<p
|
||||
v-if="rootView === REGULAR_NODE_CREATOR_VIEW || rootView === TRIGGER_NODE_CREATOR_VIEW"
|
||||
:class="$style.action"
|
||||
>
|
||||
<p v-if="showWebhook || showHttpRequest" :class="$style.action">
|
||||
{{ i18n.baseText('nodeCreator.noResults.connectUsingSuggestedNode') }}
|
||||
<template v-if="rootView === TRIGGER_NODE_CREATOR_VIEW">
|
||||
<template v-if="showWebhook">
|
||||
<N8nLink size="small" theme="text" underline @click="emit('addWebhookNode')">
|
||||
{{ i18n.baseText('nodeCreator.noResults.webhook') }}
|
||||
</N8nLink>
|
||||
{{ `${i18n.baseText('nodeCreator.noResults.or')} ` }}
|
||||
<template v-if="showHttpRequest">{{
|
||||
` ${i18n.baseText('nodeCreator.noResults.or')} `
|
||||
}}</template>
|
||||
</template>
|
||||
<N8nLink size="small" theme="text" underline @click="emit('addHttpNode')">
|
||||
<N8nLink
|
||||
v-if="showHttpRequest"
|
||||
size="small"
|
||||
theme="text"
|
||||
underline
|
||||
@click="emit('addHttpNode')"
|
||||
>
|
||||
{{ i18n.baseText('nodeCreator.noResults.httpRequest') }}
|
||||
</N8nLink>
|
||||
{{ i18n.baseText('nodeCreator.noResults.node') }}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { screen, fireEvent, waitFor } from '@testing-library/vue';
|
|||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
import { useNodeCreatorStore } from '@/features/shared/nodeCreator/nodeCreator.store';
|
||||
import { useViewStacks } from '@/features/shared/nodeCreator/composables/useViewStacks';
|
||||
import { mockSimplifiedNodeType } from '../../__tests__/utils';
|
||||
import { mockRestrictedNodeTypes, mockSimplifiedNodeType } from '../../__tests__/utils';
|
||||
import NodesListPanel from './NodesListPanel.vue';
|
||||
import { REGULAR_NODE_CREATOR_VIEW, DEBOUNCE_TIME } from '@/app/constants';
|
||||
import type { ActionTypeDescription, NodeFilterType, SimplifiedNodeType } from '@/Interface';
|
||||
|
|
@ -664,4 +664,64 @@ describe('NodesListPanel', () => {
|
|||
expect(nodeCreatorStore.openingContext).toBe('replacement');
|
||||
});
|
||||
});
|
||||
|
||||
describe('restricted node types', () => {
|
||||
const triggerNodes = [...Array(9).keys()].map(
|
||||
(n) =>
|
||||
mockSimplifiedNodeType({
|
||||
name: `Trigger Node ${n}`,
|
||||
displayName: `Trigger Node ${n}`,
|
||||
group: ['trigger'],
|
||||
}) as INodeTypeDescription,
|
||||
);
|
||||
|
||||
function renderTriggerView() {
|
||||
const { container } = getWrapperComponent(() => {
|
||||
const { setMergeNodes } = useNodeCreatorStore();
|
||||
setMergeNodes([...triggerNodes]);
|
||||
return {};
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
it('hides a restricted type while browsing', async () => {
|
||||
renderTriggerView();
|
||||
mockRestrictedNodeTypes({ 'Trigger Node 3': 'instance' });
|
||||
await nextTick();
|
||||
|
||||
screen.getByText('On app event').click();
|
||||
await nextTick();
|
||||
|
||||
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(8);
|
||||
expect(screen.queryByText('Trigger Node 3')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a restricted type last when searched for', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
renderTriggerView();
|
||||
mockRestrictedNodeTypes({ 'Trigger Node 3': 'instance' });
|
||||
await nextTick();
|
||||
|
||||
screen.getByText('On app event').click();
|
||||
await nextTick();
|
||||
|
||||
await fireEvent.input(screen.getByTestId('node-creator-search-bar'), {
|
||||
target: { value: 'Trigger Node' },
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(DEBOUNCE_TIME.INPUT.SEARCH + 1);
|
||||
await nextTick();
|
||||
|
||||
const items = screen.getAllByTestId('item-iterator-item');
|
||||
expect(items).toHaveLength(9);
|
||||
expect(items[items.length - 1]).toHaveTextContent('Trigger Node 3');
|
||||
expect(
|
||||
items[items.length - 1].querySelector('[data-test-id="node-creator-restricted-item"]'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId('node-creator-restricted-item')).toHaveLength(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@ watch(
|
|||
<NodeItem
|
||||
v-if="item.type === 'node' && !communityNode"
|
||||
:node-type="item.properties"
|
||||
:active="true"
|
||||
:active="activeItemId === item.uuid"
|
||||
:subcategory="item.subcategory"
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useNodeCreatorStore } from '@/features/shared/nodeCreator/nodeCreator.s
|
|||
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { useActions } from './useActions';
|
||||
import { mockRestrictedNodeTypes } from '../__tests__/utils';
|
||||
import {
|
||||
AGENT_NODE_TYPE,
|
||||
AI_CATEGORY_LANGUAGE_MODELS,
|
||||
|
|
@ -87,6 +88,16 @@ describe('useActions', () => {
|
|||
});
|
||||
|
||||
describe('getAddedNodesAndConnections', () => {
|
||||
it('adds nothing when any requested type is restricted', () => {
|
||||
mockRestrictedNodeTypes({ 'n8n-nodes-base.gmail': 'instance' });
|
||||
const { getAddedNodesAndConnections } = useActions();
|
||||
|
||||
expect(getAddedNodesAndConnections([{ type: 'n8n-nodes-base.gmail' }])).toEqual({
|
||||
nodes: [],
|
||||
connections: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('should insert a manual trigger node when there are no triggers', () => {
|
||||
const nodeCreatorStore = useNodeCreatorStore();
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ import {
|
|||
removePreviewToken,
|
||||
sortNodeCreateElements,
|
||||
transformNodeType,
|
||||
isNodeItemRestricted,
|
||||
} from '../nodeCreator.utils';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { PUSH_NODES_OFFSET } from '@/app/utils/nodeViewUtils';
|
||||
|
|
@ -317,7 +318,9 @@ export const useActions = () => {
|
|||
}
|
||||
|
||||
function getAddedNodesAndConnections(addedNodes: AddedNode[]): AddedNodesAndConnections {
|
||||
if (addedNodes.length === 0) {
|
||||
// Every insert path ends here — click, Enter, drag, the "no results" links, actions mode —
|
||||
// so this is where a restricted type is refused.
|
||||
if (addedNodes.length === 0 || addedNodes.some((node) => isNodeItemRestricted(node.type))) {
|
||||
return { nodes: [], connections: [] };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,9 @@ import {
|
|||
extractAiGatewaySection,
|
||||
finalizeItems,
|
||||
flattenCreateElements,
|
||||
isNodeItemRestricted,
|
||||
sinkRestrictedNodesLast,
|
||||
withoutRestrictedNodes,
|
||||
groupItemsInSections,
|
||||
isAINode,
|
||||
nodeTypesToCreateElements,
|
||||
|
|
@ -149,8 +152,10 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
|
|||
popularity: nodePopularityMap,
|
||||
}),
|
||||
);
|
||||
// The pinned client is read off baselineItems and skips the search results,
|
||||
// so it needs its own restriction check; unpinned, it sinks with the rest.
|
||||
const pinnedMcpClient =
|
||||
stack.subcategory === AI_CATEGORY_MCP_NODES
|
||||
stack.subcategory === AI_CATEGORY_MCP_NODES && !isNodeItemRestricted(AI_MCP_TOOL_NODE_TYPE)
|
||||
? stack.baselineItems?.find((item) => item.key === AI_MCP_TOOL_NODE_TYPE)
|
||||
: undefined;
|
||||
const filteredSearchResults = pinnedMcpClient
|
||||
|
|
@ -159,7 +164,10 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
|
|||
|
||||
const groupedNodes =
|
||||
groupIfAiNodes(filteredSearchResults, stack, false) ?? filteredSearchResults;
|
||||
const visibleNodes = pinnedMcpClient ? [pinnedMcpClient, ...groupedNodes] : groupedNodes;
|
||||
const visibleNodes = sinkRestrictedNodesLast(
|
||||
pinnedMcpClient ? [pinnedMcpClient, ...groupedNodes] : groupedNodes,
|
||||
isNodeItemRestricted,
|
||||
);
|
||||
// Set the active index to the second item if there's a section
|
||||
// as the first item is collapsable
|
||||
stack.activeIndex = visibleNodes.some((node) => node.type === 'section') ? 1 : 0;
|
||||
|
|
@ -167,15 +175,18 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
|
|||
return visibleNodes;
|
||||
}
|
||||
|
||||
// baselineItems stays unfiltered: it is also the search base, where restricted types stay findable.
|
||||
const browseItems = withoutRestrictedNodes(stack.baselineItems, isNodeItemRestricted);
|
||||
|
||||
// Surface n8n Connect-powered nodes in a dedicated section at the top,
|
||||
// extracted before grouping so they don't also land in the AI sections
|
||||
if (showsAiGatewaySection(stack)) {
|
||||
const extracted = extractAiGatewaySection(stack.baselineItems);
|
||||
const extracted = extractAiGatewaySection(browseItems);
|
||||
if (extracted) {
|
||||
return finalizeItems([extracted.section, ...groupIfAiNodes(extracted.rest, stack, true)]);
|
||||
}
|
||||
}
|
||||
return finalizeItems(groupIfAiNodes(stack.baselineItems, stack, true));
|
||||
return finalizeItems(groupIfAiNodes(browseItems, stack, true));
|
||||
});
|
||||
|
||||
const activeViewStack = computed<ViewStack>(() => {
|
||||
|
|
@ -265,7 +276,7 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
|
|||
return true;
|
||||
});
|
||||
|
||||
return filteredSections;
|
||||
return sinkRestrictedNodesLast(filteredSections, isNodeItemRestricted);
|
||||
});
|
||||
|
||||
const itemsBySubcategory = computed(() => subcategorizeItems(nodeCreatorStore.mergedNodes));
|
||||
|
|
|
|||
|
|
@ -22,10 +22,15 @@ import {
|
|||
matchesAliasForConnectBoost,
|
||||
nodeTypesToCreateElements,
|
||||
mapToolSubcategoryIcon,
|
||||
getNodeItemRestriction,
|
||||
isNodeItemRestricted,
|
||||
searchNodes,
|
||||
sinkRestrictedNodesLast,
|
||||
withoutRestrictedNodes,
|
||||
} from './nodeCreator.utils';
|
||||
import {
|
||||
mockActionCreateElement,
|
||||
mockRestrictedNodeTypes,
|
||||
mockNodeCreateElement,
|
||||
mockSectionCreateElement,
|
||||
mockSimplifiedNodeType,
|
||||
|
|
@ -1400,3 +1405,82 @@ describe('NodeCreator - utils', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('node item restriction lookups', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia());
|
||||
});
|
||||
|
||||
it('reports a restricted type with its scope', () => {
|
||||
mockRestrictedNodeTypes({ 'n8n-nodes-base.gmail': 'project' });
|
||||
|
||||
expect(getNodeItemRestriction('n8n-nodes-base.gmail')).toMatchObject({ scope: 'project' });
|
||||
expect(isNodeItemRestricted('n8n-nodes-base.gmail')).toBe(true);
|
||||
expect(isNodeItemRestricted('n8n-nodes-base.slack')).toBe(false);
|
||||
});
|
||||
|
||||
it('maps a credential-only node to the HTTP Request node it wraps', () => {
|
||||
mockRestrictedNodeTypes({ 'n8n-nodes-base.httpRequest': 'instance' });
|
||||
|
||||
expect(isNodeItemRestricted('n8n-creds-base.sysdigApi')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restricted node helpers', () => {
|
||||
const node = (key: string) => mockNodeCreateElement({ key });
|
||||
const isRestricted = (name: string) => name.startsWith('blocked');
|
||||
|
||||
describe('withoutRestrictedNodes', () => {
|
||||
it('drops restricted nodes at the top level', () => {
|
||||
const items = [node('a'), node('blocked-1'), node('c')];
|
||||
|
||||
expect(withoutRestrictedNodes(items, isRestricted).map((i) => i.key)).toEqual(['a', 'c']);
|
||||
});
|
||||
|
||||
it('drops restricted nodes inside a section and keeps the section', () => {
|
||||
const section = mockSectionCreateElement({ children: [node('a'), node('blocked-1')] });
|
||||
|
||||
const [result] = withoutRestrictedNodes([section], isRestricted);
|
||||
|
||||
expect(result.type).toBe('section');
|
||||
expect((result as SectionCreateElement).children.map((c) => c.key)).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('drops a section whose every child is restricted', () => {
|
||||
const section = mockSectionCreateElement({ children: [node('blocked-1')] });
|
||||
|
||||
expect(withoutRestrictedNodes([node('a'), section], isRestricted).map((i) => i.key)).toEqual([
|
||||
'a',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sinkRestrictedNodesLast', () => {
|
||||
it('moves restricted nodes after every available node and keeps both orders', () => {
|
||||
const items = [node('blocked-1'), node('a'), node('blocked-2'), node('b')];
|
||||
|
||||
expect(sinkRestrictedNodesLast(items, isRestricted).map((i) => i.key)).toEqual([
|
||||
'a',
|
||||
'b',
|
||||
'blocked-1',
|
||||
'blocked-2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('sinks inside a section and keeps the section in place', () => {
|
||||
const section = mockSectionCreateElement({
|
||||
key: 'section',
|
||||
children: [node('blocked-1'), node('a')],
|
||||
});
|
||||
const items = [section, node('b'), node('blocked-2')];
|
||||
|
||||
const result = sinkRestrictedNodesLast(items, isRestricted);
|
||||
|
||||
expect(result.map((i) => i.key)).toEqual(['section', 'b', 'blocked-2']);
|
||||
expect((result[0] as SectionCreateElement).children.map((c) => c.key)).toEqual([
|
||||
'a',
|
||||
'blocked-1',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -42,10 +42,13 @@ import type { NodeViewItemSection } from './views/viewsData';
|
|||
|
||||
import { stripToolSuffix, useAiGatewayStore } from '@/app/stores/aiGateway.store';
|
||||
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
|
||||
import { toPolicyNodeType } from '@/app/utils/credentialOnlyNodes';
|
||||
import { getNodeTypeRestriction } from '@n8n/frontend-module-type-availability-policies';
|
||||
import { useSettingsStore } from '@n8n/stores/settings.store';
|
||||
import type { NodeIconSource } from '@/app/utils/nodeIcon';
|
||||
import { getN8nAgentsNodeName } from '@/experiments/inlineAgents/useInlineAgentsExperiment';
|
||||
import { SampleTemplates } from '@/features/workflows/templates/utils/workflowSamples';
|
||||
import type { NodeTypeAvailability } from '@n8n/api-types';
|
||||
import type { IconName } from '@n8n/design-system';
|
||||
import type { INodeOutputConfiguration, NodeConnectionType } from 'n8n-workflow';
|
||||
import { NodeConnectionTypes, SEND_AND_WAIT_OPERATION } from 'n8n-workflow';
|
||||
|
|
@ -256,6 +259,52 @@ export function searchNodes(
|
|||
export function flattenCreateElements(items: INodeCreateElement[]): INodeCreateElement[] {
|
||||
return items.map((item) => (item.type === 'section' ? item.children : item)).flat();
|
||||
}
|
||||
/** Restriction lookups for node creator items, with credential-only nodes following HTTP Request. */
|
||||
export function getNodeItemRestriction(nodeTypeName: string): NodeTypeAvailability | null {
|
||||
return getNodeTypeRestriction(toPolicyNodeType(nodeTypeName));
|
||||
}
|
||||
|
||||
export function isNodeItemRestricted(nodeTypeName: string): boolean {
|
||||
return getNodeItemRestriction(nodeTypeName) !== null;
|
||||
}
|
||||
|
||||
type IsRestricted = (nodeTypeName: string) => boolean;
|
||||
|
||||
/** Browse lists: drop restricted nodes and any section that ends up empty. */
|
||||
export function withoutRestrictedNodes(
|
||||
items: INodeCreateElement[],
|
||||
isRestricted: IsRestricted,
|
||||
): INodeCreateElement[] {
|
||||
return items.flatMap((item): INodeCreateElement[] => {
|
||||
if (item.type === 'node' && isRestricted(item.key)) return [];
|
||||
if (item.type === 'section') {
|
||||
const children = withoutRestrictedNodes(item.children, isRestricted);
|
||||
return children.length > 0 ? [{ ...item, children }] : [];
|
||||
}
|
||||
return [item];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Search lists: keep restricted nodes findable, but after every usable match. Both partitions
|
||||
* keep their rank order. Sections stay in place and sink their own children.
|
||||
*/
|
||||
export function sinkRestrictedNodesLast(
|
||||
items: INodeCreateElement[],
|
||||
isRestricted: IsRestricted,
|
||||
): INodeCreateElement[] {
|
||||
const isRestrictedNode = (item: INodeCreateElement) =>
|
||||
item.type === 'node' && isRestricted(item.key);
|
||||
const available = items
|
||||
.filter((item) => !isRestrictedNode(item))
|
||||
.map((item) =>
|
||||
item.type === 'section'
|
||||
? { ...item, children: sinkRestrictedNodesLast(item.children, isRestricted) }
|
||||
: item,
|
||||
);
|
||||
return [...available, ...items.filter(isRestrictedNode)];
|
||||
}
|
||||
|
||||
export function isAINode(node: INodeCreateElement) {
|
||||
const isNode = node.type === 'node';
|
||||
if (!isNode) return false;
|
||||
|
|
|
|||
|
|
@ -27,8 +27,10 @@ and on a cold tree turbo builds them first.
|
|||
for one node type. `isNodeTypeRestricted(type)` is the plain predicate for a list of nodes. The
|
||||
shell reads restriction through these and never through the store directly.
|
||||
|
||||
`ContactInstanceAdminModal` lists the instance owners with a mail link. The shell renders it where
|
||||
a restricted node needs a contact action; the module owns its copy and behaviour.
|
||||
`ContactInstanceAdminModal` lists the instance owners with a mail link. `RestrictedNodePopover` is the
|
||||
explanation for a row in a list: the lock trigger, the hover-opened popover and the dialog. The caller
|
||||
passes the row element and whether the row is keyboard-active. The shell renders both where a
|
||||
restricted node appears; the module owns their copy and behaviour.
|
||||
|
||||
`packages/@n8n/module-cli/frontend-module-guide.md` holds the descriptor contract, the
|
||||
registration points, the import boundaries and the setup a module needs to render UI.
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
"@n8n/i18n": "workspace:*",
|
||||
"@n8n/rest-api-client": "workspace:*",
|
||||
"@n8n/stores": "workspace:*",
|
||||
"@vueuse/core": "catalog:frontend",
|
||||
"pinia": "catalog:frontend",
|
||||
"vue": "catalog:frontend"
|
||||
},
|
||||
|
|
@ -37,6 +38,7 @@
|
|||
"@n8n/vitest-config": "workspace:*",
|
||||
"@pinia/testing": "^0.1.6",
|
||||
"@testing-library/jest-dom": "catalog:frontend",
|
||||
"@testing-library/user-event": "catalog:frontend",
|
||||
"@testing-library/vue": "catalog:frontend",
|
||||
"@vitejs/plugin-vue": "catalog:frontend",
|
||||
"eslint": "catalog:",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
import { createComponentRenderer } from '@n8n/frontend-test-utils';
|
||||
import { screen } from '@testing-library/vue';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import RestrictedNodePopover from './RestrictedNodePopover.vue';
|
||||
|
||||
const DESCRIPTION =
|
||||
'An administrator blocked this node. To use it in your workflows, contact an instance administrator for access.';
|
||||
|
||||
const renderComponent = createComponentRenderer(RestrictedNodePopover, {
|
||||
props: { nodeTypeName: 'Gmail', scope: 'instance' },
|
||||
global: {
|
||||
stubs: {
|
||||
ContactInstanceAdminModal: {
|
||||
props: ['open', 'description'],
|
||||
template:
|
||||
'<div v-if="open" data-test-id="contact-instance-admin-modal">{{ description }}</div>',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const renderPopover = (props: Record<string, unknown>) => renderComponent({ props });
|
||||
|
||||
describe('RestrictedNodePopover', () => {
|
||||
it('renders only the lock while the row is neither hovered nor active', () => {
|
||||
renderPopover({ active: false });
|
||||
|
||||
expect(screen.getByTestId('node-restricted-icon')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('node-restricted-popover')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens for the keyboard-active row and names the node and the instance scope', async () => {
|
||||
renderPopover({ active: true });
|
||||
|
||||
expect(await screen.findByTestId('node-restricted-popover')).toBeInTheDocument();
|
||||
expect(screen.getByText('Gmail')).toBeInTheDocument();
|
||||
expect(screen.getByText('Restricted on this instance')).toBeInTheDocument();
|
||||
expect(screen.getByText(DESCRIPTION)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names the project scope', async () => {
|
||||
renderPopover({ active: true, scope: 'project' });
|
||||
|
||||
expect(await screen.findByText('Restricted in this project')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the generic title for an unknown scope', async () => {
|
||||
renderPopover({ active: true, scope: undefined });
|
||||
|
||||
expect(await screen.findByText('This node is restricted')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens when the anchor row is hovered and stays open while the pointer is on the popover', async () => {
|
||||
const anchor = document.createElement('div');
|
||||
document.body.appendChild(anchor);
|
||||
renderPopover({ anchor });
|
||||
|
||||
await userEvent.hover(anchor);
|
||||
const popover = await screen.findByTestId('node-restricted-popover');
|
||||
await userEvent.hover(popover);
|
||||
await userEvent.unhover(anchor);
|
||||
|
||||
expect(screen.getByTestId('node-restricted-popover')).toBeInTheDocument();
|
||||
anchor.remove();
|
||||
});
|
||||
|
||||
it('opens the contact-admin dialog for this node type', async () => {
|
||||
renderPopover({ active: true });
|
||||
expect(screen.queryByTestId('contact-instance-admin-modal')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(await screen.findByTestId('node-restricted-contact-admin'));
|
||||
|
||||
expect(screen.getByTestId('contact-instance-admin-modal')).toHaveTextContent('Gmail');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
<script setup lang="ts">
|
||||
import type { NodeTypeAvailabilityScope } from '@n8n/api-types';
|
||||
import { N8nButton, N8nIcon, N8nPopover, N8nText } from '@n8n/design-system';
|
||||
import { useI18n, type BaseTextKey } from '@n8n/i18n';
|
||||
import { unrefElement, useElementHover, type MaybeElement } from '@vueuse/core';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import ContactInstanceAdminModal from './ContactInstanceAdminModal.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
nodeTypeName: string;
|
||||
scope?: NodeTypeAvailabilityScope;
|
||||
/** The list row the popover explains. It opens beside this element, not beside the lock. */
|
||||
anchor?: MaybeElement;
|
||||
/** The row is the keyboard-active item, which opens the popover like a hover does. */
|
||||
active?: boolean;
|
||||
}>();
|
||||
|
||||
/** Leaving waits this long before closing, so the pointer can cross the gap to the popover. */
|
||||
const HOVER_GRACE_MS = 200;
|
||||
|
||||
const SCOPE_TITLE_KEY: Record<NodeTypeAvailabilityScope, BaseTextKey> = {
|
||||
instance: 'typeAvailabilityPolicies.restrictedNode.scope.instance',
|
||||
project: 'typeAvailabilityPolicies.restrictedNode.scope.project',
|
||||
};
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const anchorElement = computed(() => unrefElement(props.anchor) ?? undefined);
|
||||
const contentRef = ref<HTMLElement | null>(null);
|
||||
const anchorHovered = useElementHover(anchorElement, { delayLeave: HOVER_GRACE_MS });
|
||||
const contentHovered = useElementHover(contentRef, { delayLeave: HOVER_GRACE_MS });
|
||||
const open = computed(() => anchorHovered.value || contentHovered.value || props.active);
|
||||
|
||||
const isContactAdminOpen = ref(false);
|
||||
|
||||
const scopeKey = computed<BaseTextKey>(
|
||||
() =>
|
||||
(props.scope && SCOPE_TITLE_KEY[props.scope]) ??
|
||||
'typeAvailabilityPolicies.restrictedNode.title',
|
||||
);
|
||||
|
||||
const interpolate = computed(() => ({ nodeType: props.nodeTypeName }));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="$style.root">
|
||||
<N8nPopover
|
||||
:open="open"
|
||||
side="left"
|
||||
align="center"
|
||||
:side-offset="24"
|
||||
:reference="anchorElement"
|
||||
:suppress-auto-focus="true"
|
||||
:content-class="$style.card"
|
||||
width="254px"
|
||||
>
|
||||
<template #trigger>
|
||||
<N8nIcon
|
||||
icon="lock"
|
||||
size="small"
|
||||
:title="i18n.baseText('typeAvailabilityPolicies.restrictedNode.title')"
|
||||
data-test-id="node-restricted-icon"
|
||||
/>
|
||||
</template>
|
||||
<template #content>
|
||||
<div ref="contentRef" :class="$style.popover" data-test-id="node-restricted-popover">
|
||||
<N8nText tag="p" size="large" color="text-dark">{{ nodeTypeName }}</N8nText>
|
||||
<N8nText tag="p" size="small" color="text-light">{{ i18n.baseText(scopeKey) }}</N8nText>
|
||||
<N8nText tag="p" size="small" color="text-base" :class="$style.description">
|
||||
{{ i18n.baseText('typeAvailabilityPolicies.restrictedNode.popover.description') }}
|
||||
</N8nText>
|
||||
<N8nButton
|
||||
variant="outline"
|
||||
size="small"
|
||||
:class="$style.action"
|
||||
data-test-id="node-restricted-contact-admin"
|
||||
@click="isContactAdminOpen = true"
|
||||
>
|
||||
{{ i18n.baseText('typeAvailabilityPolicies.restrictedNode.contactAdmin') }}
|
||||
<N8nIcon icon="arrow-up-right" size="xsmall" />
|
||||
</N8nButton>
|
||||
</div>
|
||||
</template>
|
||||
</N8nPopover>
|
||||
<ContactInstanceAdminModal
|
||||
v-model:open="isContactAdminOpen"
|
||||
:description="
|
||||
i18n.baseText('typeAvailabilityPolicies.restrictedNode.contactAdmin.description', {
|
||||
interpolate,
|
||||
})
|
||||
"
|
||||
:mail-subject="
|
||||
i18n.baseText('typeAvailabilityPolicies.restrictedNode.contactAdmin.mailSubject', {
|
||||
interpolate,
|
||||
})
|
||||
"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
// Two teleported children and one visible trigger: the wrapper must not affect the slot's layout.
|
||||
.root {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
// The design system defaults popovers to --radius--xs (8px); the design uses the editor's 4px.
|
||||
.card {
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.popover {
|
||||
padding: var(--spacing--xs);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-top: var(--spacing--3xs);
|
||||
}
|
||||
|
||||
.action {
|
||||
margin-top: var(--spacing--sm);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -6,3 +6,4 @@ export {
|
|||
useNodeTypeRestriction,
|
||||
} from './composables/useNodeTypeRestriction';
|
||||
export { default as ContactInstanceAdminModal } from './components/ContactInstanceAdminModal.vue';
|
||||
export { default as RestrictedNodePopover } from './components/RestrictedNodePopover.vue';
|
||||
|
|
|
|||
|
|
@ -6864,6 +6864,9 @@ importers:
|
|||
'@n8n/stores':
|
||||
specifier: workspace:*
|
||||
version: link:../../../frontend/@n8n/stores
|
||||
'@vueuse/core':
|
||||
specifier: catalog:frontend
|
||||
version: 14.3.0(vue@3.5.26(typescript@6.0.2))
|
||||
pinia:
|
||||
specifier: catalog:frontend
|
||||
version: 2.2.4(typescript@6.0.2)(vue@3.5.26(typescript@6.0.2))
|
||||
|
|
@ -6898,6 +6901,9 @@ importers:
|
|||
'@testing-library/jest-dom':
|
||||
specifier: catalog:frontend
|
||||
version: 6.9.1
|
||||
'@testing-library/user-event':
|
||||
specifier: catalog:frontend
|
||||
version: 14.6.1(@testing-library/dom@10.4.1)
|
||||
'@testing-library/vue':
|
||||
specifier: catalog:frontend
|
||||
version: 8.1.0(@vue/compiler-sfc@3.5.26)(vue@3.5.26(typescript@6.0.2))
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user