diff --git a/cypress/e2e/4-node-creator.cy.ts b/cypress/e2e/4-node-creator.cy.ts
index 5deb04913b152..d0cc7981b1c52 100644
--- a/cypress/e2e/4-node-creator.cy.ts
+++ b/cypress/e2e/4-node-creator.cy.ts
@@ -101,8 +101,8 @@ describe('Node Creator', () => {
nodeCreatorFeature.getters.searchBar().find('input').type('{rightarrow}');
nodeCreatorFeature.getters.activeSubcategory().should('have.text', 'FTP');
nodeCreatorFeature.getters.searchBar().find('input').clear().type('file');
- // Navigate to rename action which should be the 4th item
- nodeCreatorFeature.getters.searchBar().find('input').type('{uparrow}{rightarrow}');
+ // The 1st trigger is selected, up 1x to the collapsable header, up 2x to the last action (rename)
+ nodeCreatorFeature.getters.searchBar().find('input').type('{uparrow}{uparrow}{rightarrow}');
NDVModal.getters.parameterInput('operation').find('input').should('have.value', 'Rename');
});
diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts
index 415c752ffbb63..374506d66bcf5 100644
--- a/packages/editor-ui/src/Interface.ts
+++ b/packages/editor-ui/src/Interface.ts
@@ -892,6 +892,7 @@ export interface SubcategoryItemProps {
subcategory?: string;
defaults?: INodeParameters;
forceIncludeNodes?: string[];
+ sections?: string[];
}
export interface ViewItemProps {
title: string;
@@ -937,6 +938,13 @@ export interface SubcategoryCreateElement extends CreateElementBase {
type: 'subcategory';
properties: SubcategoryItemProps;
}
+
+export interface SectionCreateElement extends CreateElementBase {
+ type: 'section';
+ title: string;
+ children: INodeCreateElement[];
+}
+
export interface ViewCreateElement extends CreateElementBase {
type: 'view';
properties: ViewItemProps;
@@ -958,6 +966,7 @@ export type INodeCreateElement =
| NodeCreateElement
| CategoryCreateElement
| SubcategoryCreateElement
+ | SectionCreateElement
| ViewCreateElement
| LabelCreateElement
| ActionCreateElement;
diff --git a/packages/editor-ui/src/components/Node/NodeCreator/Modes/NodesMode.vue b/packages/editor-ui/src/components/Node/NodeCreator/Modes/NodesMode.vue
index 995909129dbdf..d2bcdb2ab9274 100644
--- a/packages/editor-ui/src/components/Node/NodeCreator/Modes/NodesMode.vue
+++ b/packages/editor-ui/src/components/Node/NodeCreator/Modes/NodesMode.vue
@@ -16,7 +16,7 @@ import { useRootStore } from '@/stores/n8nRoot.store';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
import { TriggerView, RegularView, AIView, AINodesView } from '../viewsData';
-import { transformNodeType } from '../utils';
+import { flattenCreateElements, transformNodeType } from '../utils';
import { useViewStacks } from '../composables/useViewStacks';
import { useKeyboardNavigation } from '../composables/useKeyboardNavigation';
import ItemsRenderer from '../Renderers/ItemsRenderer.vue';
@@ -71,6 +71,7 @@ function onSelected(item: INodeCreateElement) {
forceIncludeNodes: item.properties.forceIncludeNodes,
baseFilter: baseSubcategoriesFilter,
itemsMapper: subcategoriesMapper,
+ sections: item.properties.sections,
});
telemetry.trackNodesPanel('nodeCreateList.onSubcategorySelected', {
@@ -160,7 +161,8 @@ function subcategoriesMapper(item: INodeCreateElement) {
return item;
}
-function baseSubcategoriesFilter(item: INodeCreateElement) {
+function baseSubcategoriesFilter(item: INodeCreateElement): boolean {
+ if (item.type === 'section') return item.children.every(baseSubcategoriesFilter);
if (item.type !== 'node') return false;
const hasTriggerGroup = item.properties.group.includes('trigger');
@@ -180,10 +182,10 @@ function arrowLeft() {
}
function onKeySelect(activeItemId: string) {
- const mergedItems = [
- ...(activeViewStack.value.items || []),
- ...(globalSearchItemsDiff.value || []),
- ];
+ const mergedItems = flattenCreateElements([
+ ...(activeViewStack.value.items ?? []),
+ ...(globalSearchItemsDiff.value ?? []),
+ ]);
const item = mergedItems.find((i) => i.uuid === activeItemId);
if (!item) return;
diff --git a/packages/editor-ui/src/components/Node/NodeCreator/Panel/NodesListPanel.vue b/packages/editor-ui/src/components/Node/NodeCreator/Panel/NodesListPanel.vue
index 35b92b93e0047..625b1e6a58e3e 100644
--- a/packages/editor-ui/src/components/Node/NodeCreator/Panel/NodesListPanel.vue
+++ b/packages/editor-ui/src/components/Node/NodeCreator/Panel/NodesListPanel.vue
@@ -39,22 +39,36 @@ const searchPlaceholder = computed(() =>
const nodeCreatorView = computed(() => useNodeCreatorStore().selectedView);
+function getDefaultActiveIndex(search: string = ''): number {
+ if (activeViewStack.value.activeIndex) {
+ return activeViewStack.value.activeIndex;
+ }
+
+ if (activeViewStack.value.mode === 'actions') {
+ // For actions, set the active focus to the first action, not category
+ return 1;
+ } else if (activeViewStack.value.sections) {
+ // For sections, set the active focus to the first node, not section (unless searching)
+ return search ? 0 : 1;
+ }
+
+ return 0;
+}
+
function onSearch(value: string) {
if (activeViewStack.value.uuid) {
updateCurrentViewStack({ search: value });
- void setActiveItemIndex(activeViewStack.value.activeIndex ?? 0);
+ void setActiveItemIndex(getDefaultActiveIndex(value));
}
}
function onTransitionEnd() {
- // For actions, set the active focus to the first action, not category
- const newStackIndex = activeViewStack.value.mode === 'actions' ? 1 : 0;
- void setActiveItemIndex(activeViewStack.value.activeIndex || 0 || newStackIndex);
+ void setActiveItemIndex(getDefaultActiveIndex());
}
onMounted(() => {
attachKeydownEvent();
- void setActiveItemIndex(activeViewStack.value.activeIndex ?? 0);
+ void setActiveItemIndex(getDefaultActiveIndex());
});
onUnmounted(() => {
diff --git a/packages/editor-ui/src/components/Node/NodeCreator/Renderers/ItemsRenderer.vue b/packages/editor-ui/src/components/Node/NodeCreator/Renderers/ItemsRenderer.vue
index 60f506d4f430a..266766b7127b9 100644
--- a/packages/editor-ui/src/components/Node/NodeCreator/Renderers/ItemsRenderer.vue
+++ b/packages/editor-ui/src/components/Node/NodeCreator/Renderers/ItemsRenderer.vue
@@ -8,6 +8,8 @@ import SubcategoryItem from '../ItemTypes/SubcategoryItem.vue';
import LabelItem from '../ItemTypes/LabelItem.vue';
import ActionItem from '../ItemTypes/ActionItem.vue';
import ViewItem from '../ItemTypes/ViewItem.vue';
+import CategorizedItemsRenderer from './CategorizedItemsRenderer.vue';
+
export interface Props {
elements: INodeCreateElement[];
activeIndex?: number;
@@ -110,46 +112,55 @@ watch(
@leave="leave"
>
-
+
-
-
-
-
-
-
-
-
+
wrappedEmit('selected', child)"
+ >
+
+
+
-
diff --git a/packages/editor-ui/src/components/Node/NodeCreator/__tests__/ItemsRenderer.test.ts b/packages/editor-ui/src/components/Node/NodeCreator/__tests__/ItemsRenderer.test.ts
index 62474acd43d94..e799015503a87 100644
--- a/packages/editor-ui/src/components/Node/NodeCreator/__tests__/ItemsRenderer.test.ts
+++ b/packages/editor-ui/src/components/Node/NodeCreator/__tests__/ItemsRenderer.test.ts
@@ -7,6 +7,7 @@ import {
mockNodeCreateElement,
mockActionCreateElement,
mockViewCreateElement,
+ mockSectionCreateElement,
} from './utils';
import ItemsRenderer from '../Renderers/ItemsRenderer.vue';
import { createComponentRenderer } from '@/__tests__/render';
@@ -18,13 +19,29 @@ describe('ItemsRenderer', () => {
const items = [
mockSubcategoryCreateElement({ title: 'Subcategory 1' }),
mockLabelCreateElement('subcategory', { key: 'label1' }),
- mockNodeCreateElement('subcategory', { displayName: 'Node 1', name: 'node1' }),
- mockNodeCreateElement('subcategory', { displayName: 'Node 2', name: 'node2' }),
- mockNodeCreateElement('subcategory', { displayName: 'Node 3', name: 'node3' }),
+ mockNodeCreateElement(
+ { subcategory: 'subcategory' },
+ { displayName: 'Node 1', name: 'node1' },
+ ),
+ mockNodeCreateElement(
+ { subcategory: 'subcategory' },
+ { displayName: 'Node 2', name: 'node2' },
+ ),
+ mockNodeCreateElement(
+ { subcategory: 'subcategory' },
+ { displayName: 'Node 3', name: 'node3' },
+ ),
mockLabelCreateElement('subcategory', { key: 'label2' }),
- mockNodeCreateElement('subcategory', { displayName: 'Node 2', name: 'node2' }),
- mockNodeCreateElement('subcategory', { displayName: 'Node 3', name: 'node3' }),
+ mockNodeCreateElement(
+ { subcategory: 'subcategory' },
+ { displayName: 'Node 2', name: 'node2' },
+ ),
+ mockNodeCreateElement(
+ { subcategory: 'subcategory' },
+ { displayName: 'Node 3', name: 'node3' },
+ ),
mockSubcategoryCreateElement({ title: 'Subcategory 2' }),
+ mockSectionCreateElement(),
];
const { container } = renderComponent({
@@ -40,8 +57,10 @@ describe('ItemsRenderer', () => {
const nodeItems = container.querySelectorAll('.iteratorItem .nodeItem');
const labels = container.querySelectorAll('.iteratorItem .label');
const subCategories = container.querySelectorAll('.iteratorItem .subCategory');
+ const sections = container.querySelectorAll('.categoryItem');
- expect(nodeItems.length).toBe(5);
+ expect(sections.length).toBe(1);
+ expect(nodeItems.length).toBe(7); // 5 nodes in subcategories | 2 nodes in a section
expect(labels.length).toBe(2);
expect(subCategories.length).toBe(2);
});
diff --git a/packages/editor-ui/src/components/Node/NodeCreator/__tests__/utils.test.ts b/packages/editor-ui/src/components/Node/NodeCreator/__tests__/utils.test.ts
new file mode 100644
index 0000000000000..a89a5e2202c9d
--- /dev/null
+++ b/packages/editor-ui/src/components/Node/NodeCreator/__tests__/utils.test.ts
@@ -0,0 +1,49 @@
+import type { SectionCreateElement } from '@/Interface';
+import { groupItemsInSections } from '../utils';
+import { mockNodeCreateElement } from './utils';
+
+describe('NodeCreator - utils', () => {
+ describe('groupItemsInSections', () => {
+ it('should handle multiple sections (with "other" section)', () => {
+ const node1 = mockNodeCreateElement({ key: 'popularNode' });
+ const node2 = mockNodeCreateElement({ key: 'newNode' });
+ const node3 = mockNodeCreateElement({ key: 'otherNode' });
+ const result = groupItemsInSections(
+ [node1, node2, node3],
+ [
+ { key: 'popular', title: 'Popular', items: [node1.key] },
+ { key: 'new', title: 'New', items: [node2.key] },
+ ],
+ ) as SectionCreateElement[];
+ expect(result.length).toEqual(3);
+ expect(result[0].title).toEqual('Popular');
+ expect(result[0].children).toEqual([node1]);
+ expect(result[1].title).toEqual('New');
+ expect(result[1].children).toEqual([node2]);
+ expect(result[2].title).toEqual('Other');
+ expect(result[2].children).toEqual([node3]);
+ });
+
+ it('should handle no sections', () => {
+ const node1 = mockNodeCreateElement({ key: 'popularNode' });
+ const node2 = mockNodeCreateElement({ key: 'newNode' });
+ const node3 = mockNodeCreateElement({ key: 'otherNode' });
+ const result = groupItemsInSections([node1, node2, node3], []);
+ expect(result).toEqual([node1, node2, node3]);
+ });
+
+ it('should handle only empty sections', () => {
+ const node1 = mockNodeCreateElement({ key: 'popularNode' });
+ const node2 = mockNodeCreateElement({ key: 'newNode' });
+ const node3 = mockNodeCreateElement({ key: 'otherNode' });
+ const result = groupItemsInSections(
+ [node1, node2, node3],
+ [
+ { key: 'popular', title: 'Popular', items: [] },
+ { key: 'new', title: 'New', items: ['someOtherNodeType'] },
+ ],
+ );
+ expect(result).toEqual([node1, node2, node3]);
+ });
+ });
+});
diff --git a/packages/editor-ui/src/components/Node/NodeCreator/__tests__/utils.ts b/packages/editor-ui/src/components/Node/NodeCreator/__tests__/utils.ts
index 0acb65fa7df58..f8470218680fa 100644
--- a/packages/editor-ui/src/components/Node/NodeCreator/__tests__/utils.ts
+++ b/packages/editor-ui/src/components/Node/NodeCreator/__tests__/utils.ts
@@ -9,6 +9,7 @@ import type {
ViewCreateElement,
LabelCreateElement,
ActionCreateElement,
+ SectionCreateElement,
} from '@/Interface';
import { v4 as uuidv4 } from 'uuid';
@@ -74,14 +75,15 @@ const mockLabelItemProps = (overrides?: Partial): LabelItemProps
});
export const mockNodeCreateElement = (
- subcategory?: string,
- overrides?: Partial,
+ overrides?: Partial,
+ nodeTypeOverrides?: Partial,
): NodeCreateElement => ({
uuid: uuidv4(),
key: uuidv4(),
type: 'node',
- subcategory: subcategory || 'sampleSubcategory',
- properties: mockSimplifiedNodeType(overrides),
+ subcategory: 'sampleSubcategory',
+ properties: mockSimplifiedNodeType(nodeTypeOverrides),
+ ...overrides,
});
export const mockSubcategoryCreateElement = (
@@ -93,6 +95,17 @@ export const mockSubcategoryCreateElement = (
properties: mockSubcategoryItemProps(overrides),
});
+export const mockSectionCreateElement = (
+ overrides?: Partial,
+): SectionCreateElement => ({
+ uuid: uuidv4(),
+ key: 'popular',
+ type: 'section',
+ title: 'Popular',
+ children: [mockNodeCreateElement(), mockNodeCreateElement()],
+ ...overrides,
+});
+
export const mockViewCreateElement = (
overrides?: Partial,
): ViewCreateElement => ({
diff --git a/packages/editor-ui/src/components/Node/NodeCreator/composables/useViewStacks.ts b/packages/editor-ui/src/components/Node/NodeCreator/composables/useViewStacks.ts
index 62576cd1649ea..b1f58dce935eb 100644
--- a/packages/editor-ui/src/components/Node/NodeCreator/composables/useViewStacks.ts
+++ b/packages/editor-ui/src/components/Node/NodeCreator/composables/useViewStacks.ts
@@ -1,33 +1,32 @@
-import { computed, nextTick, ref } from 'vue';
-import { defineStore } from 'pinia';
-import { v4 as uuid } from 'uuid';
-import type {
- NodeConnectionType,
- INodeCreateElement,
- NodeFilterType,
- SimplifiedNodeType,
-} from '@/Interface';
+import type { INodeCreateElement, NodeFilterType, SimplifiedNodeType } from '@/Interface';
import {
AI_CODE_NODE_TYPE,
AI_OTHERS_NODE_CREATOR_VIEW,
DEFAULT_SUBCATEGORY,
TRIGGER_NODE_CREATOR_VIEW,
} from '@/constants';
+import { defineStore } from 'pinia';
+import { v4 as uuid } from 'uuid';
+import { computed, nextTick, ref } from 'vue';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
-import { useKeyboardNavigation } from './useKeyboardNavigation';
import {
- transformNodeType,
- subcategorizeItems,
- sortNodeCreateElements,
+ flattenCreateElements,
+ groupItemsInSections,
searchNodes,
+ sortNodeCreateElements,
+ subcategorizeItems,
+ transformNodeType,
} from '../utils';
+
+import type { NodeViewItem, NodeViewItemSection } from '@/components/Node/NodeCreator/viewsData';
+import { AINodesView } from '@/components/Node/NodeCreator/viewsData';
import { useI18n } from '@/composables/useI18n';
+import { useKeyboardNavigation } from './useKeyboardNavigation';
-import type { INodeInputFilter } from 'n8n-workflow';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
-import { AINodesView, type NodeViewItem } from '@/components/Node/NodeCreator/viewsData';
+import type { INodeInputFilter, NodeConnectionType } from 'n8n-workflow';
interface ViewStack {
uuid?: string;
@@ -55,6 +54,7 @@ interface ViewStack {
baseFilter?: (item: INodeCreateElement) => boolean;
itemsMapper?: (item: INodeCreateElement) => INodeCreateElement;
panelClass?: string;
+ sections?: NodeViewItemSection[];
}
export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
@@ -72,7 +72,9 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
if (stack.search && searchBaseItems.value) {
const searchBase =
- searchBaseItems.value.length > 0 ? searchBaseItems.value : stack.baselineItems;
+ searchBaseItems.value.length > 0
+ ? searchBaseItems.value
+ : flattenCreateElements(stack.baselineItems ?? []);
return extendItemsWithUUID(searchNodes(stack.search || '', searchBase));
}
@@ -83,10 +85,12 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
const stack = viewStacks.value[viewStacks.value.length - 1];
if (!stack) return {};
+ const flatBaselineItems = flattenCreateElements(stack.baselineItems ?? []);
+
return {
...stack,
items: activeStackItems.value,
- hasSearch: (stack.baselineItems || []).length > 8 || stack?.hasSearch,
+ hasSearch: flatBaselineItems.length > 8 || stack?.hasSearch,
};
});
@@ -114,6 +118,8 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
});
});
+ const itemsBySubcategory = computed(() => subcategorizeItems(nodeCreatorStore.mergedNodes));
+
async function gotoCompatibleConnectionView(
connectionType: NodeConnectionType,
isOutput?: boolean,
@@ -182,10 +188,19 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
const stack = viewStacks.value[viewStacks.value.length - 1];
if (!stack || !activeViewStack.value.uuid) return;
- let stackItems =
- stack?.items ??
- subcategorizeItems(nodeCreatorStore.mergedNodes)[stack?.subcategory ?? DEFAULT_SUBCATEGORY] ??
- [];
+ let stackItems = stack?.items ?? [];
+
+ if (!stack?.items) {
+ const subcategory = stack?.subcategory ?? DEFAULT_SUBCATEGORY;
+ const itemsInSubcategory = itemsBySubcategory.value[subcategory];
+ 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
diff --git a/packages/editor-ui/src/components/Node/NodeCreator/utils.ts b/packages/editor-ui/src/components/Node/NodeCreator/utils.ts
index 73ebd5cf7b94c..02764a71d9bfd 100644
--- a/packages/editor-ui/src/components/Node/NodeCreator/utils.ts
+++ b/packages/editor-ui/src/components/Node/NodeCreator/utils.ts
@@ -4,10 +4,14 @@ import type {
SubcategorizedNodeTypes,
SimplifiedNodeType,
INodeCreateElement,
+ SectionCreateElement,
} from '@/Interface';
import { AI_SUBCATEGORY, CORE_NODES_CATEGORY, DEFAULT_SUBCATEGORY } from '@/constants';
import { v4 as uuidv4 } from 'uuid';
+
import { sublimeSearch } from '@/utils/sortUtils';
+import { i18n } from '@/plugins/i18n';
+import type { NodeViewItemSection } from './viewsData';
export function transformNodeType(
node: SimplifiedNodeType,
@@ -75,3 +79,42 @@ export function searchNodes(searchFilter: string, items: INodeCreateElement[]) {
return result;
}
+
+export function flattenCreateElements(items: INodeCreateElement[]): INodeCreateElement[] {
+ return items.map((item) => (item.type === 'section' ? item.children : item)).flat();
+}
+
+export function groupItemsInSections(
+ items: INodeCreateElement[],
+ sections: NodeViewItemSection[],
+): INodeCreateElement[] {
+ const itemsBySection = items.reduce((acc: Record, item) => {
+ const section = sections.find((s) => s.items.includes(item.key));
+ const key = section?.key ?? 'other';
+ acc[key] = [...(acc[key] ?? []), item];
+ return acc;
+ }, {});
+
+ const result: SectionCreateElement[] = sections
+ .map(
+ (section): SectionCreateElement => ({
+ type: 'section',
+ key: section.key,
+ title: section.title,
+ children: itemsBySection[section.key],
+ }),
+ )
+ .concat({
+ type: 'section',
+ key: 'other',
+ title: i18n.baseText('nodeCreator.sectionNames.other'),
+ children: itemsBySection.other,
+ })
+ .filter((section) => section.children);
+
+ if (result.length <= 1) {
+ return items;
+ }
+
+ return result;
+}
diff --git a/packages/editor-ui/src/components/Node/NodeCreator/viewsData.ts b/packages/editor-ui/src/components/Node/NodeCreator/viewsData.ts
index 466ae9da1940a..c5a84b04c3a0e 100644
--- a/packages/editor-ui/src/components/Node/NodeCreator/viewsData.ts
+++ b/packages/editor-ui/src/components/Node/NodeCreator/viewsData.ts
@@ -36,6 +36,12 @@ import type { SimplifiedNodeType } from '@/Interface';
import type { INodeTypeDescription } from 'n8n-workflow';
import { NodeConnectionType } from 'n8n-workflow';
+export interface NodeViewItemSection {
+ key: string;
+ title: string;
+ items: string[];
+}
+
export interface NodeViewItem {
key: string;
type: string;
@@ -49,6 +55,7 @@ export interface NodeViewItem {
connectionType?: NodeConnectionType;
panelClass?: string;
group?: string[];
+ sections?: NodeViewItemSection[];
description?: string;
forceIncludeNodes?: string[];
};
@@ -342,6 +349,13 @@ export function RegularView(nodes: SimplifiedNodeType[]) {
properties: {
title: TRANSFORM_DATA_SUBCATEGORY,
icon: 'pen',
+ sections: [
+ {
+ key: 'popular',
+ title: i18n.baseText('nodeCreator.sectionNames.popular'),
+ items: [],
+ },
+ ],
},
},
{
diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json
index 74923b7427168..848ac999b605b 100644
--- a/packages/editor-ui/src/plugins/i18n/locales/en.json
+++ b/packages/editor-ui/src/plugins/i18n/locales/en.json
@@ -898,6 +898,8 @@
"nodeCreator.subcategoryNames.tools": "Tools",
"nodeCreator.subcategoryNames.vectorStores": "Vector Stores",
"nodeCreator.subcategoryNames.miscellaneous": "Miscellaneous",
+ "nodeCreator.sectionNames.popular": "Popular",
+ "nodeCreator.sectionNames.other": "Other",
"nodeCreator.triggerHelperPanel.addAnotherTrigger": "Add another trigger",
"nodeCreator.triggerHelperPanel.addAnotherTriggerDescription": "Triggers start your workflow. Workflows can have multiple triggers.",
"nodeCreator.triggerHelperPanel.title": "When should this workflow run?",