feat: Hide generated AI tools listed in NODES_EXCLUDE and empty node creator entries (#39577)

This commit is contained in:
Elias Meire 2026-09-30 14:02:32 +00:00 committed by GitHub
parent 45edf565df
commit 5e376b69ed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 503 additions and 248 deletions

View File

@ -31,8 +31,9 @@ export class NodesConfig {
/**
* Node types to exclude from loading. Default excludes `ExecuteCommand` and `LocalFileTrigger` for security.
* Set to an empty array to allow all node types.
* Generated tool variants are accepted too. The base node stays available.
*
* @example '["n8n-nodes-base.hackerNews"]'
* @example '["n8n-nodes-base.hackerNews", "n8n-nodes-base.dateTimeTool"]'
*/
@Env('NODES_EXCLUDE')
exclude: JsonStringArray = ['n8n-nodes-base.executeCommand', 'n8n-nodes-base.localFileTrigger'];

View File

@ -10,6 +10,7 @@ import type {
ICredentialType,
INodeProperties,
INodeTypeDescription,
KnownNodesAndCredentials,
NodeLoader,
} from 'n8n-workflow';
import { UserError } from 'n8n-workflow';
@ -873,6 +874,7 @@ describe('LoadNodesAndCredentials', () => {
createHitlTools.mockClear();
instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock(), mock(), mock());
instance.excludeNodes = [];
});
it('should keep types in memory after post-processing for post-processors to read', async () => {
@ -926,6 +928,53 @@ describe('LoadNodesAndCredentials', () => {
expect(createHitlTools).toHaveBeenCalledWith(instance.types, expectedKnown);
});
it('should drop generated tool types that NODES_EXCLUDE lists', async () => {
createAiTools.mockImplementationOnce(
(types: { nodes: INodeTypeDescription[] }, known: KnownNodesAndCredentials) => {
for (const name of ['test-package.testNodeTool', 'test-package.otherNodeTool']) {
types.nodes.push({ name } as INodeTypeDescription);
known.nodes[name] = { className: 'TestNode', sourcePath: 'Test.node.js' };
}
known.credentials.testApi = {
className: 'TestApi',
sourcePath: 'TestApi.credentials.js',
supportedNodes: [
'test-package.testNode',
'test-package.testNodeTool',
'test-package.otherNodeTool',
],
};
},
);
instance.excludeNodes = ['test-package.testNodeTool'];
instance.loaders = {
'test-package': mock<NodeLoader>({
packageName: 'test-package',
known: {
nodes: { testNode: { className: 'TestNode', sourcePath: 'Test.node.js' } },
credentials: {},
},
types: { nodes: [{ name: 'testNode' }], credentials: [] },
ensureTypesLoaded: vi.fn().mockResolvedValue(undefined),
}),
};
await instance.postProcessLoaders();
expect(instance.types.nodes.map(({ name }) => name)).toEqual([
'test-package.testNode',
'test-package.otherNodeTool',
]);
expect(Object.keys(instance.knownNodes)).toEqual([
'test-package.testNode',
'test-package.otherNodeTool',
]);
expect(instance.knownCredentials.testApi.supportedNodes).toEqual([
'test-package.testNode',
'test-package.otherNodeTool',
]);
});
describe('atomic registry swap (known, loaded, types)', () => {
const createLoader = () =>
mock<NodeLoader>({
@ -975,6 +1024,7 @@ describe('LoadNodesAndCredentials', () => {
beforeEach(() => {
instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock(), mock(), mock());
instance.excludeNodes = [];
});
it('should return a snapshot of types with package-namespaced node names', async () => {

View File

@ -15,7 +15,7 @@ import { NodeTypes } from '@/node-types';
describe('NodeTypes', () => {
const logger = mock<Logger>();
const loadNodesAndCredentials = mock<LoadNodesAndCredentials>();
const loadNodesAndCredentials = mock<LoadNodesAndCredentials>({ excludeNodes: [] });
const nodeTypes: NodeTypes = new NodeTypes(logger, loadNodesAndCredentials);
@ -433,6 +433,43 @@ describe('NodeTypes', () => {
});
});
describe('tool variants listed in NODES_EXCLUDE', () => {
beforeEach(() => {
loadNodesAndCredentials.excludeNodes = ['n8n-nodes-base.testNodeTool'];
});
afterEach(() => {
loadNodesAndCredentials.excludeNodes = [];
});
it('should not resolve the excluded tool variant', () => {
expect(() => nodeTypes.getByNameAndVersion('n8n-nodes-base.testNodeTool')).toThrow(
UnrecognizedNodeTypeError,
);
});
it('should still resolve the base node', () => {
expect(() => nodeTypes.getByNameAndVersion('n8n-nodes-base.testNode')).not.toThrow();
});
it('should not list versions for the excluded tool variant', () => {
expect(nodeTypes.getSupportedVersions('n8n-nodes-base.testNodeTool')).toBeUndefined();
});
it('should not describe the excluded tool variant', () => {
expect(() =>
nodeTypes.getNodeTypeDescriptions([{ name: 'n8n-nodes-base.testNodeTool', version: 1 }]),
).toThrow(UnrecognizedNodeTypeError);
});
it('should resolve the excluded tool variant to itself', () => {
expect(nodeTypes.resolveBaseName('n8n-nodes-base.testNodeTool')).toEqual({
baseName: 'n8n-nodes-base.testNodeTool',
isSyntheticTool: false,
});
});
});
describe('getSupportedVersions', () => {
it('should return the single version of a plain node type', () => {
expect(nodeTypes.getSupportedVersions('n8n-nodes-base.hitlNode')).toEqual([1]);

View File

@ -59,6 +59,7 @@ function makeRealNodeTypes(): NodeTypes {
recognizesNode: (name: string) => name in baseTypes,
getNode: (name: string) => baseTypes[name],
loadedNodes: {} as Record<string, LoadedClass<INodeType>>,
excludeNodes: [] as string[],
};
return new NodeTypes(mock(), loader as never);

View File

@ -631,6 +631,23 @@ export class LoadNodesAndCredentials {
createAiTools(this.types, this.known);
createHitlTools(this.types, this.known);
// Loaders filter base nodes only. Generated tool variants are filtered here.
this.types.nodes = this.types.nodes.filter(({ name }) => !this.excludeNodes.includes(name));
this.known.nodes = Object.fromEntries(
Object.entries(this.known.nodes).filter(([name]) => !this.excludeNodes.includes(name)),
);
this.known.credentials = Object.fromEntries(
Object.entries(this.known.credentials).map(([name, credential]) => [
name,
{
...credential,
supportedNodes: credential.supportedNodes?.filter(
(node) => !this.excludeNodes.includes(node),
),
},
]),
);
this.injectCustomApiCallOptions();
this.injectContextEstablishmentHooks();

View File

@ -28,10 +28,15 @@ export class NodeTypes implements INodeTypes {
* A "synthetic tool" has no implementation of its own: workflows persist
* names like `gmailTool`, and the registry fabricates that node on demand by
* converting the `gmail` base node into an agent tool.
*
* A tool name listed in `NODES_EXCLUDE` is never synthetic. It resolves to
* itself, so loading it fails as an unrecognized type.
*/
resolveBaseName(nodeTypeName: string): { baseName: string; isSyntheticTool: boolean } {
const isSyntheticTool =
nodeTypeName.endsWith('Tool') && !this.loadNodesAndCredentials.recognizesNode(nodeTypeName);
nodeTypeName.endsWith('Tool') &&
!this.loadNodesAndCredentials.recognizesNode(nodeTypeName) &&
!this.loadNodesAndCredentials.excludeNodes.includes(nodeTypeName);
return {
baseName: isSyntheticTool ? stripToolSuffix(nodeTypeName) : nodeTypeName,
isSyntheticTool,

View File

@ -867,6 +867,7 @@ describe('WorkflowBuilderService - node type loading', () => {
mock(),
mock(),
);
loadNodesAndCredentials.excludeNodes = [];
// Real LazyPackageDirectoryLoader reading from the mocked filesystem
const loader = new LazyPackageDirectoryLoader(packageDir);

View File

@ -1,41 +1,25 @@
<script setup lang="ts">
import camelCase from 'lodash/camelCase';
import { computed } from 'vue';
import type {
ActionTypeDescription,
INodeCreateElement,
NodeCreateElement,
NodeFilterType,
NodeTypeSelectedPayload,
} from '@/Interface';
import type { INodeCreateElement, NodeTypeSelectedPayload } from '@/Interface';
import {
TRIGGER_NODE_CREATOR_VIEW,
HTTP_REQUEST_NODE_TYPE,
WEBHOOK_NODE_TYPE,
REGULAR_NODE_CREATOR_VIEW,
AI_NODE_CREATOR_VIEW,
AI_OTHERS_NODE_CREATOR_VIEW,
AI_MCP_TOOL_NODE_TYPE,
HITL_SUBCATEGORY,
MESSAGE_AN_AGENT_NODE_TYPE,
AI_CATEGORY_MCP_NODES,
ADD_EMPTY_GROUP_NODE_CREATOR_ITEM,
REQUEST_NODE_FORM_URL,
} from '@/app/constants';
import type { BaseTextKey } from '@n8n/i18n';
import { useNodeCreatorStore } from '@/features/shared/nodeCreator/nodeCreator.store';
import { TriggerView, RegularView, AIView, AINodesView } from '../../views/viewsData';
import {
flattenCreateElements,
filterAndSearchNodes,
getNodeCreatorSearchItems,
prepareCommunityNodeDetailsViewStack,
transformNodeType,
getRootSearchCallouts,
shouldShowCommunityNodeDetails,
getHumanInTheLoopActions,
isNodeItemRestricted,
sinkRestrictedNodesLast,
} from '../../nodeCreator.utils';
@ -73,8 +57,16 @@ const i18n = useI18n();
const { isRagStarterCalloutVisible, openSampleWorkflowTemplate } = useCalloutHelpers();
const nodeCreatorStore = useNodeCreatorStore();
const { mergedNodes, actions, onSubcategorySelected } = nodeCreatorStore;
const { pushViewStack, popViewStack, isAiSubcategoryView, isHitlSubcategoryView } = useViewStacks();
const { mergedNodes, onSubcategorySelected } = nodeCreatorStore;
const {
pushViewStack,
popViewStack,
isAiSubcategoryView,
isHitlSubcategoryView,
getFilteredActions,
subcategoryStack,
viewStackByKey,
} = useViewStacks();
const { setAddedNodeActionParameters, nodeCreateElementToNodeTypeSelectedPayload } = useActions();
const emptyCanvasGroupsEnabled = useEmptyCanvasGroupsFlag();
@ -127,20 +119,6 @@ const isSearchResultEmpty = computed(() => {
});
const showSuggestionFooter = computed(() => isMcpCategory.value || isSearchResultEmpty.value);
function getFilteredActions(
node: NodeCreateElement,
actions: Record<string, ActionTypeDescription[]>,
) {
const nodeActions = actions?.[node.key] || [];
if (activeViewStack.value.subcategory === HITL_SUBCATEGORY) {
return getHumanInTheLoopActions(nodeActions);
}
if (activeViewStack.value.actionsFilter) {
return activeViewStack.value.actionsFilter(nodeActions);
}
return nodeActions;
}
function onSelected(item: INodeCreateElement) {
if (item.key === ADD_EMPTY_GROUP_NODE_CREATOR_ITEM && shouldHideEmptyGroupCommand.value) return;
@ -149,43 +127,13 @@ function onSelected(item: INodeCreateElement) {
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);
// If the info message exists in locale, add it to the info field of the view
const infoKey = `nodeCreator.subcategoryInfos.${subcategoryKey}` as BaseTextKey;
const info = i18n.baseText(infoKey);
const extendedInfo = info !== infoKey ? { info } : {};
const nodeIcon = item.properties.icon
? ({ type: 'icon', name: item.properties.icon } as const)
: undefined;
pushViewStack({
subcategory: item.key,
mode: 'nodes',
title,
nodeIcon,
...extendedInfo,
...(item.properties.panelClass ? { panelClass: item.properties.panelClass } : {}),
...(item.properties.connectionType ? { connectionType: item.properties.connectionType } : {}),
rootView: activeViewStack.value.rootView,
forceIncludeNodes: item.properties.forceIncludeNodes,
baseFilter: baseSubcategoriesFilter,
itemsMapper: subcategoriesMapper,
sections: item.properties.sections,
items: item.properties.items,
hideActions: item.properties.hideActions,
actionsFilter: item.properties.actionsFilter,
});
onSubcategorySelected({
subcategory: item.key,
});
pushViewStack(subcategoryStack(item, activeViewStack.value.rootView));
onSubcategorySelected({ subcategory: item.key });
}
if (item.type === 'node') {
const payload = nodeCreateElementToNodeTypeSelectedPayload(item);
let nodeActions = getFilteredActions(item, actions);
let nodeActions = getFilteredActions(activeViewStack.value, item, nodeCreatorStore.actions);
const notInstalledCommunityNode =
isCommunityPackageName(item.key) && !nodeTypesStore.getIsNodeInstalled(item.key);
const nodeIcon = getNodeIconSource(
@ -215,7 +163,11 @@ function onSelected(item: INodeCreateElement) {
notInstalledCommunityNode
) {
if (!nodeActions.length) {
nodeActions = getFilteredActions(item, communityNodesAndActions.value.actions);
nodeActions = getFilteredActions(
activeViewStack.value,
item,
communityNodesAndActions.value.actions,
);
}
const viewStack = prepareCommunityNodeDetailsViewStack(
@ -269,33 +221,12 @@ function onSelected(item: INodeCreateElement) {
}
if (item.type === 'view') {
const views = {
[TRIGGER_NODE_CREATOR_VIEW]: TriggerView,
[REGULAR_NODE_CREATOR_VIEW]: RegularView,
[AI_NODE_CREATOR_VIEW]: AIView,
[AI_OTHERS_NODE_CREATOR_VIEW]: AINodesView,
};
const itemKey = item.key as keyof typeof views;
const matchedView = views[itemKey];
if (!matchedView) {
console.warn(`No view found for ${itemKey}`);
const stack = viewStackByKey(item.key);
if (!stack) {
console.warn(`No view found for ${item.key}`);
return;
}
const view = matchedView(mergedNodes);
pushViewStack({
title: view.title,
subtitle: view?.subtitle ?? '',
info: view?.info ?? '',
items: view.items as INodeCreateElement[],
hasSearch: true,
rootView: view.value as NodeFilterType,
mode: 'nodes',
// Root search should include all nodes and command items.
searchItems: getNodeCreatorSearchItems(mergedNodes, view.items),
});
pushViewStack(stack);
}
if (item.type === 'link') {
@ -311,42 +242,6 @@ function onSelected(item: INodeCreateElement) {
}
}
function subcategoriesMapper(item: INodeCreateElement) {
if (item.type !== 'node') return item;
const hasTriggerGroup = item.properties.group.includes('trigger');
const nodeActions = getFilteredActions(item, actions);
const hasActions = nodeActions.length > 0;
if (hasTriggerGroup && hasActions) {
if (item.properties?.codex) {
// Store the original name in the alias so we can search for it
item.properties.codex.alias = [
...(item.properties.codex?.alias || []),
item.properties.displayName,
];
}
item.properties.displayName = item.properties.displayName.replace(' Trigger', '');
}
return item;
}
function baseSubcategoriesFilter(item: INodeCreateElement): boolean {
if (item.type === 'section') return true;
if (item.type !== 'node') return false;
const hasTriggerGroup = item.properties.group.includes('trigger');
const nodeActions = getFilteredActions(item, actions);
const hasActions = nodeActions.length > 0;
const isTriggerRootView = activeViewStack.value.rootView === TRIGGER_NODE_CREATOR_VIEW;
if (isTriggerRootView) {
return hasActions || hasTriggerGroup;
}
return hasActions || !hasTriggerGroup;
}
const globalCallouts = computed<INodeCreateElement[]>(() => [
...getRootSearchCallouts(
activeViewStack.value.search ?? '',

View File

@ -6,16 +6,30 @@ import type { INodeTypeDescription } from 'n8n-workflow';
import { useNodeCreatorStore } from '@/features/shared/nodeCreator/nodeCreator.store';
import { useViewStacks } from '@/features/shared/nodeCreator/composables/useViewStacks';
import { mockRestrictedNodeTypes } from '@n8n/frontend-module-type-availability-policies/__tests__/mocks';
import { mockNodeTypeDescription } from '@/__tests__/mocks';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { mockActionCreateElement, mockSimplifiedNodeType } from '../../__tests__/utils';
import NodesListPanel from './NodesListPanel.vue';
import {
REGULAR_NODE_CREATOR_VIEW,
AI_CATEGORY_AGENTS,
AI_CATEGORY_LANGUAGE_MODELS,
AI_CATEGORY_ROOT_NODES,
AI_NODE_CREATOR_VIEW,
AI_SUBCATEGORY,
CORE_NODES_CATEGORY,
DEBOUNCE_TIME,
FLOWS_CONTROL_SUBCATEGORY,
HELPERS_SUBCATEGORY,
HITL_SUBCATEGORY,
HUMAN_IN_THE_LOOP_CATEGORY,
TRANSFORM_DATA_SUBCATEGORY,
OTHER_TRIGGER_NODES_SUBCATEGORY,
REGULAR_NODE_CREATOR_VIEW,
SCHEDULE_TRIGGER_NODE_TYPE,
CUSTOM_API_CALL_KEY,
HTTP_REQUEST_NODE_TYPE,
NODE_CREATOR_OPEN_SOURCES,
TRIGGER_NODE_CREATOR_VIEW,
} from '@/app/constants';
import type { ActionTypeDescription, NodeFilterType, SimplifiedNodeType } from '@/Interface';
import { createComponentRenderer } from '@/__tests__/render';
@ -120,7 +134,7 @@ describe('NodesListPanel', () => {
await fireEvent.click(container.querySelector('.backButton')!);
await nextTick();
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(10);
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(9);
});
it('should hide a listed trigger whose node type is not loaded', async () => {
@ -145,6 +159,20 @@ describe('NodesListPanel', () => {
group: ['input'],
}) as INodeTypeDescription,
);
const categorizedNodes = [
[CORE_NODES_CATEGORY, TRANSFORM_DATA_SUBCATEGORY],
[CORE_NODES_CATEGORY, FLOWS_CONTROL_SUBCATEGORY],
[CORE_NODES_CATEGORY, HELPERS_SUBCATEGORY],
[HUMAN_IN_THE_LOOP_CATEGORY, HITL_SUBCATEGORY],
].map(
([category, subcategory]) =>
mockSimplifiedNodeType({
name: subcategory,
displayName: subcategory,
group: ['input'],
codex: { categories: [category], subcategories: { [category]: [subcategory] } },
}) as INodeTypeDescription,
);
const wrapperComponent = defineComponent({
components: {
@ -163,6 +191,7 @@ describe('NodesListPanel', () => {
},
setup(props) {
const { setMergeNodes, setSelectedView } = useNodeCreatorStore();
vi.spyOn(useNodeTypesStore(), 'isNodeTypeUnavailable').mockReturnValue(false);
watch(
() => props.nodeTypes,
@ -187,14 +216,24 @@ describe('NodesListPanel', () => {
renderComponent({
pinia: createPinia(),
props: {
nodeTypes: mockedNodes,
nodeTypes: [...mockedNodes, ...categorizedNodes],
selectedView: REGULAR_NODE_CREATOR_VIEW,
},
});
await nextTick();
expect(screen.getByText('What happens next?')).toBeInTheDocument();
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(7);
expect(screen.queryAllByTestId('item-iterator-item').map((item) => item.textContent)).toEqual(
[
expect.stringContaining('Action in an app'),
expect.stringContaining('Data transformation'),
expect.stringContaining('Flow'),
expect.stringContaining('Core'),
expect.stringContaining('Human review'),
expect.stringContaining('Add another trigger'),
expect.stringContaining('Group'),
],
);
screen.getByText('Action in an app').click();
await nextTick();
@ -327,8 +366,8 @@ describe('NodesListPanel', () => {
target: { value: ' Node 1' },
});
await waitFor(() => expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(1));
expect(screen.queryByText('Node 1')).toBeInTheDocument();
await waitFor(() => expect(screen.queryByText('Node 1')).toBeInTheDocument());
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(1);
expect(screen.getByTestId('node-creator-search-bar')).toHaveValue('Node 1');
});
@ -390,7 +429,10 @@ describe('NodesListPanel', () => {
const workflowDocumentStore = useWorkflowDocumentStore(createWorkflowDocumentId(''));
workflowDocumentStore.setNodes([source]);
const appNode = mockNodeTypeDescription({ name: 'appNode', displayName: 'App Node' });
useNodeTypesStore().setNodeTypes([appNode]);
const nodeCreatorStore = useNodeCreatorStore();
nodeCreatorStore.setMergeNodes([appNode]);
nodeCreatorStore.openingContext = 'replacement';
nodeCreatorStore.setSelectedView(REGULAR_NODE_CREATOR_VIEW);
});
@ -838,6 +880,107 @@ describe('NodesListPanel', () => {
);
});
describe('entries without nodes', () => {
const otherTrigger = mockNodeTypeDescription({
name: 'otherTrigger',
displayName: 'Other Trigger',
group: ['trigger'],
codex: {
categories: [CORE_NODES_CATEGORY],
subcategories: { [CORE_NODES_CATEGORY]: [OTHER_TRIGGER_NODES_SUBCATEGORY] },
},
});
const agent = mockNodeTypeDescription({
name: 'agent',
displayName: 'Agent',
codex: { categories: [AI_SUBCATEGORY], subcategories: { AI: [AI_CATEGORY_AGENTS] } },
});
const languageModel = mockNodeTypeDescription({
name: 'languageModel',
displayName: 'Language Model',
codex: { categories: [AI_SUBCATEGORY], subcategories: { AI: [AI_CATEGORY_LANGUAGE_MODELS] } },
});
const unlistedAiNode = mockNodeTypeDescription({
name: 'unlistedAiNode',
displayName: 'Unlisted AI Node',
codex: { categories: [AI_SUBCATEGORY], subcategories: { AI: [AI_CATEGORY_ROOT_NODES] } },
});
function renderView(
view: NodeFilterType,
nodes: INodeTypeDescription[],
restricted: Record<string, 'instance'> = {},
) {
getWrapperComponent(() => {
const { setMergeNodes, setSelectedView } = useNodeCreatorStore();
useNodeTypesStore().setNodeTypes(nodes);
mockRestrictedNodeTypes(restricted);
setMergeNodes(nodes);
setSelectedView(view);
return {};
});
return nextTick();
}
it('should not list a subcategory that contains no nodes', async () => {
await renderView(TRIGGER_NODE_CREATOR_VIEW, []);
expect(screen.queryByText('Other ways...')).not.toBeInTheDocument();
});
it('should list a subcategory that contains a node', async () => {
await renderView(TRIGGER_NODE_CREATOR_VIEW, [otherTrigger]);
expect(screen.getByText('Other ways...')).toBeInTheDocument();
});
it('should not list a view whose subcategories contain no nodes', async () => {
await renderView(AI_NODE_CREATOR_VIEW, [agent]);
expect(screen.getByText('Agent')).toBeInTheDocument();
expect(screen.queryByText('Other AI Nodes')).not.toBeInTheDocument();
});
it('should not list a view inside a section that contains no nodes', async () => {
await renderView(REGULAR_NODE_CREATOR_VIEW, [agent]);
expect(screen.queryByText('Add another trigger')).not.toBeInTheDocument();
});
it('should list a view inside a section that contains a node', async () => {
const trigger = mockSimplifiedNodeType({
name: 'otherTrigger',
group: ['trigger'],
codex: {
categories: [CORE_NODES_CATEGORY],
subcategories: { [CORE_NODES_CATEGORY]: [OTHER_TRIGGER_NODES_SUBCATEGORY] },
},
}) as INodeTypeDescription;
await renderView(REGULAR_NODE_CREATOR_VIEW, [agent, trigger]);
expect(screen.getByText('Add another trigger')).toBeInTheDocument();
});
it('should list a view when one of its subcategories contains a node', async () => {
await renderView(AI_NODE_CREATOR_VIEW, [agent, languageModel]);
expect(screen.getByText('Other AI Nodes')).toBeInTheDocument();
});
it('should not count links as content', async () => {
await renderView(REGULAR_NODE_CREATOR_VIEW, [unlistedAiNode]);
expect(screen.getByText('What happens next?')).toBeInTheDocument();
expect(screen.queryByText('AI')).not.toBeInTheDocument();
});
it('should not count restricted nodes as content', async () => {
await renderView(TRIGGER_NODE_CREATOR_VIEW, [otherTrigger], { otherTrigger: 'instance' });
expect(screen.queryByText('Other ways...')).not.toBeInTheDocument();
});
});
describe('restricted node types', () => {
const triggerNodes = [...Array(9).keys()].map(
(n) =>

View File

@ -1,16 +1,10 @@
<script setup lang="ts">
import type { INodeCreateElement, NodeFilterType, SimplifiedNodeType } from '@/Interface';
import type { NodeFilterType } from '@/Interface';
import {
ADD_EMPTY_GROUP_NODE_CREATOR_ITEM,
AI_EVALUATION,
AI_NODE_CREATOR_VIEW,
AI_OTHERS_NODE_CREATOR_VIEW,
AI_UNCATEGORIZED_CATEGORY,
DEBOUNCE_TIME,
HUMAN_IN_THE_LOOP_CATEGORY,
isNodeCreatorOpenFromConnection,
REGULAR_NODE_CREATOR_VIEW,
TRIGGER_NODE_CREATOR_VIEW,
} from '@/app/constants';
import { computed, onMounted, onUnmounted, watch } from 'vue';
@ -22,15 +16,7 @@ import { useDebounce } from '@n8n/composables/useDebounce';
import { useI18n } from '@n8n/i18n';
import { useKeyboardNavigation } from '../../composables/useKeyboardNavigation';
import { useViewStacks, type ViewStack } from '../../composables/useViewStacks';
import {
AINodesView,
AIView,
HitlToolView,
RegularView,
TriggerView,
type NodeView,
} from '../../views/viewsData';
import { getNodeCreatorSearchItems } from '../../nodeCreator.utils';
import { NODE_CREATOR_VIEWS } from '../../views/viewsData';
import ActionsRenderer from '../Modes/ActionsMode.vue';
import AgentsRenderer from '../Modes/AgentsMode.vue';
import NodesRenderer from '../Modes/NodesMode.vue';
@ -49,7 +35,7 @@ const i18n = useI18n();
const { callDebounced, debounce } = useDebounce();
const { mergedNodes } = useNodeCreatorStore();
const { pushViewStack, popViewStack, updateCurrentViewStack } = useViewStacks();
const { pushViewStack, popViewStack, updateCurrentViewStack, viewStack } = useViewStacks();
const { setActiveItemIndex, attachKeydownEvent, detachKeydownEvent } = useKeyboardNavigation();
const nodeCreatorStore = useNodeCreatorStore();
const uiStore = useUIStore();
@ -211,16 +197,6 @@ onUnmounted(() => {
watch(
() => nodeCreatorView.value,
(selectedView) => {
const views: Record<NodeFilterType, (nodes: SimplifiedNodeType[]) => NodeView> = {
[TRIGGER_NODE_CREATOR_VIEW]: TriggerView,
[REGULAR_NODE_CREATOR_VIEW]: RegularView,
[AI_NODE_CREATOR_VIEW]: AIView,
[AI_OTHERS_NODE_CREATOR_VIEW]: AINodesView,
[AI_UNCATEGORIZED_CATEGORY]: AINodesView,
[AI_EVALUATION]: AINodesView,
[HUMAN_IN_THE_LOOP_CATEGORY]: HitlToolView,
};
const additionalOptions: Partial<Record<NodeFilterType, Partial<ViewStack>>> = {
// is a root view, but it should behave like a subcategory view
[HUMAN_IN_THE_LOOP_CATEGORY]: {
@ -228,31 +204,15 @@ watch(
},
};
const matchedView = views[selectedView];
if (!matchedView) {
console.warn(`No view found for ${selectedView}`);
return;
}
const view = matchedView(mergedNodes);
const viewItems = isAddingInsideGroup.value
const view = NODE_CREATOR_VIEWS[selectedView](mergedNodes);
const items = isAddingInsideGroup.value
? // Forbid adding groups inside groups (nesting not supported)
view.items.filter((item) => item.key !== ADD_EMPTY_GROUP_NODE_CREATOR_ITEM)
: view.items;
const viewStack: ViewStack = {
title: view.title,
subtitle: view?.subtitle ?? '',
items: viewItems as INodeCreateElement[],
nodeIcon: view.nodeIcon,
info: view.info,
hasSearch: true,
mode: 'nodes',
rootView: selectedView,
// Root search should include all nodes and command items.
searchItems: getNodeCreatorSearchItems(mergedNodes, viewItems),
pushViewStack({
...viewStack({ ...view, items }, selectedView),
...additionalOptions[selectedView],
};
pushViewStack(viewStack);
});
const pending = nodeCreatorStore.consumePendingInitialViewStack();
if (pending) {

View File

@ -19,10 +19,12 @@ import {
AI_SECTION_RECOMMENDED_TOOLS,
AI_SUBCATEGORY,
DEFAULT_SUBCATEGORY,
HITL_SUBCATEGORY,
HUMAN_IN_THE_LOOP_CATEGORY,
NEW_TOOL_CATEGORIES,
TRIGGER_NODE_CREATOR_VIEW,
} from '@/app/constants';
import camelCase from 'lodash/camelCase';
import { defineStore } from 'pinia';
import { v4 as uuid } from 'uuid';
import { computed, nextTick, ref } from 'vue';
@ -34,6 +36,8 @@ import {
extractAiGatewaySection,
finalizeItems,
flattenCreateElements,
getHumanInTheLoopActions,
getNodeCreatorSearchItems,
isNodeItemRestricted,
sinkRestrictedNodesLast,
withoutRestrictedNodes,
@ -48,9 +52,14 @@ import {
transformNodeType,
} from '../nodeCreator.utils';
import { isNodeViewItem, type NodeViewItem, type NodeViewItemSection } from '../views/viewsData';
import { AINodesView } from '../views/viewsData';
import { useI18n } from '@n8n/i18n';
import {
isNodeViewItem,
type NodeView,
type NodeViewItem,
type NodeViewItemSection,
} from '../views/viewsData';
import { AINodesView, NODE_CREATOR_VIEWS, isNodeCreatorView } from '../views/viewsData';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
import { useKeyboardNavigation } from './useKeyboardNavigation';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
@ -542,58 +551,175 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
);
}
function getFilteredActions(
stack: ViewStack,
node: NodeCreateElement,
actions: Record<string, ActionTypeDescription[]>,
) {
const nodeActions = actions?.[node.key] || [];
if (stack.subcategory === HITL_SUBCATEGORY) {
return getHumanInTheLoopActions(nodeActions);
}
if (stack.actionsFilter) {
return stack.actionsFilter(nodeActions);
}
return nodeActions;
}
function isListedInSubcategory(stack: ViewStack, item: INodeCreateElement): boolean {
if (item.type === 'section') return true;
if (item.type !== 'node') return false;
const hasTriggerGroup = item.properties.group.includes('trigger');
const hasActions = getFilteredActions(stack, item, nodeCreatorStore.actions).length > 0;
if (stack.rootView === TRIGGER_NODE_CREATOR_VIEW) {
return hasActions || hasTriggerGroup;
}
return hasActions || !hasTriggerGroup;
}
function mapSubcategoryItem(stack: ViewStack, item: INodeCreateElement) {
if (item.type !== 'node') return item;
const hasTriggerGroup = item.properties.group.includes('trigger');
const hasActions = getFilteredActions(stack, item, nodeCreatorStore.actions).length > 0;
if (!hasTriggerGroup || !hasActions) return item;
// Items are cached and share `codex` with the node types, so copy before changing them
const { properties } = item;
return {
...item,
properties: {
...properties,
displayName: properties.displayName.replace(' Trigger', ''),
...(properties.codex && {
// Store the original name in the alias so we can search for it
codex: {
...properties.codex,
alias: [...(properties.codex.alias ?? []), properties.displayName],
},
}),
},
};
}
function subcategoryStack(item: SubcategoryCreateElement, rootView?: NodeFilterType): ViewStack {
const subcategoryKey = camelCase(item.properties.title);
// If the info message exists in locale, add it to the info field of the view
const infoKey = `nodeCreator.subcategoryInfos.${subcategoryKey}` as BaseTextKey;
const info = i18n.baseText(infoKey);
const stack: ViewStack = {
subcategory: item.key,
mode: 'nodes',
title: i18n.baseText(`nodeCreator.subcategoryNames.${subcategoryKey}` as BaseTextKey),
nodeIcon: item.properties.icon ? { type: 'icon', name: item.properties.icon } : undefined,
...(info !== infoKey ? { info } : {}),
...(item.properties.panelClass ? { panelClass: item.properties.panelClass } : {}),
...(item.properties.connectionType ? { connectionType: item.properties.connectionType } : {}),
rootView,
forceIncludeNodes: item.properties.forceIncludeNodes,
sections: item.properties.sections,
items: item.properties.items,
hideActions: item.properties.hideActions,
actionsFilter: item.properties.actionsFilter,
};
return {
...stack,
baseFilter: (element) => isListedInSubcategory(stack, element),
itemsMapper: (element) => mapSubcategoryItem(stack, element),
};
}
function viewStack(view: NodeView, rootView: NodeFilterType = view.value): ViewStack {
return {
title: view.title,
subtitle: view.subtitle ?? '',
info: view.info,
nodeIcon: view.nodeIcon,
items: view.items as INodeCreateElement[],
hasSearch: true,
mode: 'nodes',
rootView,
// Root search should include all nodes and command items.
searchItems: getNodeCreatorSearchItems(nodeCreatorStore.mergedNodes, view.items),
};
}
function viewStackByKey(key: string): ViewStack | undefined {
if (!isNodeCreatorView(key)) return undefined;
return viewStack(NODE_CREATOR_VIEWS[key](nodeCreatorStore.mergedNodes));
}
function subcategoryItems(stack: ViewStack): INodeCreateElement[] {
const items = (itemsBySubcategory.value[stack.subcategory ?? DEFAULT_SUBCATEGORY] ?? []).filter(
(item) => settingsStore.isAskAiEnabled || item.key !== AI_TRANSFORM_NODE_TYPE,
);
return stack.sections ? groupItemsInSections(items, stack.sections) : items;
}
function listedStackItems(stack: ViewStack): INodeCreateElement[] {
// Views list some nodes by hand. Every other list is built from the loaded node types.
const listed =
stack.items?.filter(
(item) => item.type !== 'node' || !nodeTypesStore.isNodeTypeUnavailable(item.key),
) ?? subcategoryItems(stack);
const forced = nodeCreatorStore.mergedNodes
.filter((node) => stack.forceIncludeNodes?.includes(node.name))
.map((node) => transformNodeType(node, stack.subcategory));
const items = [...listed, ...forced];
return stack.baseFilter ? items.filter(stack.baseFilter) : items;
}
/** A stack's items before mapping and sorting, without entries that open an empty list. */
function collectStackItems(stack: ViewStack): INodeCreateElement[] {
return listedStackItems(stack).flatMap((item): INodeCreateElement[] => {
if (item.type !== 'section') return opensNodes(item, stack.rootView) ? [item] : [];
const children = item.children.filter((child) => opensNodes(child, stack.rootView));
return children.length > 0 ? [{ ...item, children }] : [];
});
}
function opensNodes(item: INodeCreateElement, rootView?: NodeFilterType): boolean {
if (item.type === 'subcategory') return hasBrowsableNodes(subcategoryStack(item, rootView));
if (item.type === 'view') {
const stack = viewStackByKey(item.key);
return stack !== undefined && hasBrowsableNodes(stack);
}
return true;
}
// Links and callouts are not content. Direct nodes are checked first so that
// nested lists are resolved only when the stack has no node of its own.
function hasBrowsableNodes(stack: ViewStack): boolean {
const items = flattenCreateElements(
withoutRestrictedNodes(listedStackItems(stack), isNodeItemRestricted),
);
return (
items.some((item) => item.type === 'node') ||
items.some(
(item) =>
(item.type === 'subcategory' || item.type === 'view') && opensNodes(item, stack.rootView),
)
);
}
function setStackBaselineItems() {
const stack = getLastActiveStack();
if (!stack || !activeViewStack.value.uuid) return;
// Views list some nodes by hand. Every other list is built from the loaded node types.
let stackItems = (stack?.items ?? []).filter(
(item) => item.type !== 'node' || !nodeTypesStore.isNodeTypeUnavailable(item.key),
);
if (!stack?.items) {
const subcategory = stack?.subcategory ?? DEFAULT_SUBCATEGORY;
let itemsInSubcategory: INodeCreateElement[] | undefined =
itemsBySubcategory.value[subcategory];
const isAskAiEnabled = settingsStore.isAskAiEnabled;
if (!isAskAiEnabled) {
itemsInSubcategory =
itemsInSubcategory?.filter((item) => item.key !== AI_TRANSFORM_NODE_TYPE) ?? [];
}
const sections = stack.sections;
if (sections) {
stackItems = groupItemsInSections(itemsInSubcategory, sections);
} else {
stackItems = itemsInSubcategory;
}
}
// Ensure that the nodes specified in `stack.forceIncludeNodes` are always included,
// regardless of whether the subcategory is matched
if ((stack.forceIncludeNodes ?? []).length > 0) {
const matchedNodes = nodeCreatorStore.mergedNodes
.filter((item) => stack.forceIncludeNodes?.includes(item.name))
.map((item) => transformNodeType(item, stack.subcategory));
stackItems.push(...matchedNodes);
}
if (stack.baseFilter) {
stackItems = stackItems.filter(stack.baseFilter);
}
if (stack.itemsMapper) {
stackItems = stackItems.map(stack.itemsMapper);
}
const items = collectStackItems(stack);
const mapped = stack.itemsMapper ? items.map(stack.itemsMapper) : items;
// Sort only if non-root view
if (!stack.items) {
stackItems = sortNodeCreateElements(stackItems);
}
updateCurrentViewStack({ baselineItems: stackItems });
updateCurrentViewStack({
baselineItems: stack.items ? mapped : sortNodeCreateElements(mapped),
});
}
function pushViewStack(
@ -651,6 +777,10 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
globalSearchItemsDiff,
isAiSubcategoryView,
gotoCompatibleConnectionView,
getFilteredActions,
subcategoryStack,
viewStack,
viewStackByKey,
resetViewStacks,
updateCurrentViewStack,
pushViewStack,

View File

@ -12,6 +12,7 @@ import {
AI_CATEGORY_TOOLS,
AI_CATEGORY_VECTOR_STORES,
AI_CODE_TOOL_LANGCHAIN_NODE_TYPE,
AI_EVALUATION,
AI_NODE_CREATOR_VIEW,
AI_OTHERS_NODE_CREATOR_VIEW,
AI_SUBCATEGORY,
@ -69,6 +70,7 @@ import { useEvaluationStore } from '@/features/ai/evaluation.ee/evaluation.store
import { useTemplatesStore } from '@/features/workflows/templates/templates.store';
import type {
CommandCreateElement,
NodeFilterType,
SectionCreateElement,
SimplifiedNodeType,
ViewCreateElement,
@ -127,7 +129,7 @@ export function isNodeViewSection(item: NodeViewElement): item is SectionCreateE
}
export interface NodeView {
value: string;
value: NodeFilterType;
title: string;
info?: string;
subtitle?: string;
@ -660,18 +662,16 @@ export function RegularView(nodes: SimplifiedNodeType[]) {
],
};
const hasAINodes = (nodes ?? []).some((node) => node.codex?.categories?.includes(AI_SUBCATEGORY));
if (hasAINodes)
view.items.unshift({
key: AI_NODE_CREATOR_VIEW,
type: 'view',
properties: {
title: i18n.baseText('nodeCreator.aiPanel.langchainAiNodes'),
icon: 'robot',
description: i18n.baseText('nodeCreator.aiPanel.nodesForAi'),
borderless: true,
},
} as NodeViewItem);
view.items.unshift({
key: AI_NODE_CREATOR_VIEW,
type: 'view',
properties: {
title: i18n.baseText('nodeCreator.aiPanel.langchainAiNodes'),
icon: 'robot',
description: i18n.baseText('nodeCreator.aiPanel.nodesForAi'),
borderless: true,
},
} as NodeViewItem);
view.items.push({
type: 'section',
@ -716,3 +716,18 @@ export function HitlToolView(nodes: SimplifiedNodeType[]): NodeView {
},
};
}
export const NODE_CREATOR_VIEWS: Record<NodeFilterType, (nodes: SimplifiedNodeType[]) => NodeView> =
{
[TRIGGER_NODE_CREATOR_VIEW]: TriggerView,
[REGULAR_NODE_CREATOR_VIEW]: RegularView,
[AI_NODE_CREATOR_VIEW]: AIView,
[AI_OTHERS_NODE_CREATOR_VIEW]: AINodesView,
[AI_UNCATEGORIZED_CATEGORY]: AINodesView,
[AI_EVALUATION]: AINodesView,
[HUMAN_IN_THE_LOOP_CATEGORY]: HitlToolView,
};
export function isNodeCreatorView(key: string): key is NodeFilterType {
return Object.hasOwn(NODE_CREATOR_VIEWS, key);
}