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" > -
+
- - - - - - - - + + + +
+ + + + + + + + +
-
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?",