feat(editor): Add sections to create node panel (#7831)

This PR sets the stage for the node creator to handle sections within
subcategories. No visible changes result from this PR; the next PR will
define sections and assign nodes accordingly.

Sections are configurable in
`packages/editor-ui/src/components/Node/NodeCreator/viewsData.ts`:
```
{
	type: 'subcategory',
	key: FILES_SUBCATEGORY,
	category: CORE_NODES_CATEGORY,
	properties: {
		title: FILES_SUBCATEGORY,
		icon: 'file-alt',
		sections: [
			{
				key: 'popular',
				title: i18n.baseText('nodeCreator.sectionNames.popular'),
				items: ['n8n-nodes-base.readBinaryFiles', 'n8n-nodes-base.compression'],
			},
		],
	},
},
```

For example:
<img width="302" alt="image"
src="https://github.com/n8n-io/n8n/assets/8850410/74470c07-f4ea-4306-bd4a-8d33bd769b86">

---------

Co-authored-by: Michael Kret <michael.k@radency.com>
This commit is contained in:
Elias Meire 2023-12-04 10:02:07 +01:00 committed by GitHub
parent 485a0c73cb
commit 39fa8d21bb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 271 additions and 80 deletions

View file

@ -101,8 +101,8 @@ describe('Node Creator', () => {
nodeCreatorFeature.getters.searchBar().find('input').type('{rightarrow}'); nodeCreatorFeature.getters.searchBar().find('input').type('{rightarrow}');
nodeCreatorFeature.getters.activeSubcategory().should('have.text', 'FTP'); nodeCreatorFeature.getters.activeSubcategory().should('have.text', 'FTP');
nodeCreatorFeature.getters.searchBar().find('input').clear().type('file'); nodeCreatorFeature.getters.searchBar().find('input').clear().type('file');
// Navigate to rename action which should be the 4th item // 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}{rightarrow}'); nodeCreatorFeature.getters.searchBar().find('input').type('{uparrow}{uparrow}{rightarrow}');
NDVModal.getters.parameterInput('operation').find('input').should('have.value', 'Rename'); NDVModal.getters.parameterInput('operation').find('input').should('have.value', 'Rename');
}); });

View file

@ -902,6 +902,7 @@ export interface SubcategoryItemProps {
subcategory?: string; subcategory?: string;
defaults?: INodeParameters; defaults?: INodeParameters;
forceIncludeNodes?: string[]; forceIncludeNodes?: string[];
sections?: string[];
} }
export interface ViewItemProps { export interface ViewItemProps {
title: string; title: string;
@ -947,6 +948,13 @@ export interface SubcategoryCreateElement extends CreateElementBase {
type: 'subcategory'; type: 'subcategory';
properties: SubcategoryItemProps; properties: SubcategoryItemProps;
} }
export interface SectionCreateElement extends CreateElementBase {
type: 'section';
title: string;
children: INodeCreateElement[];
}
export interface ViewCreateElement extends CreateElementBase { export interface ViewCreateElement extends CreateElementBase {
type: 'view'; type: 'view';
properties: ViewItemProps; properties: ViewItemProps;
@ -968,6 +976,7 @@ export type INodeCreateElement =
| NodeCreateElement | NodeCreateElement
| CategoryCreateElement | CategoryCreateElement
| SubcategoryCreateElement | SubcategoryCreateElement
| SectionCreateElement
| ViewCreateElement | ViewCreateElement
| LabelCreateElement | LabelCreateElement
| ActionCreateElement; | ActionCreateElement;

View file

@ -16,7 +16,7 @@ import { useRootStore } from '@/stores/n8nRoot.store';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store'; import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
import { TriggerView, RegularView, AIView, AINodesView } from '../viewsData'; import { TriggerView, RegularView, AIView, AINodesView } from '../viewsData';
import { transformNodeType } from '../utils'; import { flattenCreateElements, transformNodeType } from '../utils';
import { useViewStacks } from '../composables/useViewStacks'; import { useViewStacks } from '../composables/useViewStacks';
import { useKeyboardNavigation } from '../composables/useKeyboardNavigation'; import { useKeyboardNavigation } from '../composables/useKeyboardNavigation';
import ItemsRenderer from '../Renderers/ItemsRenderer.vue'; import ItemsRenderer from '../Renderers/ItemsRenderer.vue';
@ -71,6 +71,7 @@ function onSelected(item: INodeCreateElement) {
forceIncludeNodes: item.properties.forceIncludeNodes, forceIncludeNodes: item.properties.forceIncludeNodes,
baseFilter: baseSubcategoriesFilter, baseFilter: baseSubcategoriesFilter,
itemsMapper: subcategoriesMapper, itemsMapper: subcategoriesMapper,
sections: item.properties.sections,
}); });
telemetry.trackNodesPanel('nodeCreateList.onSubcategorySelected', { telemetry.trackNodesPanel('nodeCreateList.onSubcategorySelected', {
@ -160,7 +161,8 @@ function subcategoriesMapper(item: INodeCreateElement) {
return item; 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; if (item.type !== 'node') return false;
const hasTriggerGroup = item.properties.group.includes('trigger'); const hasTriggerGroup = item.properties.group.includes('trigger');
@ -180,10 +182,10 @@ function arrowLeft() {
} }
function onKeySelect(activeItemId: string) { function onKeySelect(activeItemId: string) {
const mergedItems = [ const mergedItems = flattenCreateElements([
...(activeViewStack.value.items || []), ...(activeViewStack.value.items ?? []),
...(globalSearchItemsDiff.value || []), ...(globalSearchItemsDiff.value ?? []),
]; ]);
const item = mergedItems.find((i) => i.uuid === activeItemId); const item = mergedItems.find((i) => i.uuid === activeItemId);
if (!item) return; if (!item) return;

View file

@ -39,22 +39,36 @@ const searchPlaceholder = computed(() =>
const nodeCreatorView = computed(() => useNodeCreatorStore().selectedView); 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) { function onSearch(value: string) {
if (activeViewStack.value.uuid) { if (activeViewStack.value.uuid) {
updateCurrentViewStack({ search: value }); updateCurrentViewStack({ search: value });
void setActiveItemIndex(activeViewStack.value.activeIndex ?? 0); void setActiveItemIndex(getDefaultActiveIndex(value));
} }
} }
function onTransitionEnd() { function onTransitionEnd() {
// For actions, set the active focus to the first action, not category void setActiveItemIndex(getDefaultActiveIndex());
const newStackIndex = activeViewStack.value.mode === 'actions' ? 1 : 0;
void setActiveItemIndex(activeViewStack.value.activeIndex || 0 || newStackIndex);
} }
onMounted(() => { onMounted(() => {
attachKeydownEvent(); attachKeydownEvent();
void setActiveItemIndex(activeViewStack.value.activeIndex ?? 0); void setActiveItemIndex(getDefaultActiveIndex());
}); });
onUnmounted(() => { onUnmounted(() => {

View file

@ -8,6 +8,8 @@ import SubcategoryItem from '../ItemTypes/SubcategoryItem.vue';
import LabelItem from '../ItemTypes/LabelItem.vue'; import LabelItem from '../ItemTypes/LabelItem.vue';
import ActionItem from '../ItemTypes/ActionItem.vue'; import ActionItem from '../ItemTypes/ActionItem.vue';
import ViewItem from '../ItemTypes/ViewItem.vue'; import ViewItem from '../ItemTypes/ViewItem.vue';
import CategorizedItemsRenderer from './CategorizedItemsRenderer.vue';
export interface Props { export interface Props {
elements: INodeCreateElement[]; elements: INodeCreateElement[];
activeIndex?: number; activeIndex?: number;
@ -110,10 +112,19 @@ watch(
@leave="leave" @leave="leave"
> >
<slot /> <slot />
<div v-for="item in elements" :key="item.uuid">
<div v-if="renderedItems.includes(item)">
<CategorizedItemsRenderer
v-if="item.type === 'section'"
:elements="item.children"
expanded
:category="item.title"
@selected="(child) => wrappedEmit('selected', child)"
>
</CategorizedItemsRenderer>
<div <div
v-for="item in elements" v-else
:key="item.uuid"
data-test-id="item-iterator-item"
:class="{ :class="{
clickable: !disabled, clickable: !disabled,
[$style.active]: activeItemId === item.uuid, [$style.active]: activeItemId === item.uuid,
@ -121,11 +132,11 @@ watch(
[$style[item.type]]: true, [$style[item.type]]: true,
}" }"
ref="iteratorItems" ref="iteratorItems"
data-test-id="item-iterator-item"
:data-keyboard-nav-type="item.type !== 'label' ? item.type : undefined" :data-keyboard-nav-type="item.type !== 'label' ? item.type : undefined"
:data-keyboard-nav-id="item.uuid" :data-keyboard-nav-id="item.uuid"
@click="wrappedEmit('selected', item)" @click="wrappedEmit('selected', item)"
> >
<div v-if="renderedItems.includes(item)">
<label-item v-if="item.type === 'label'" :item="item" /> <label-item v-if="item.type === 'label'" :item="item" />
<subcategory-item v-if="item.type === 'subcategory'" :item="item.properties" /> <subcategory-item v-if="item.type === 'subcategory'" :item="item.properties" />
@ -149,7 +160,7 @@ watch(
:class="$style.viewItem" :class="$style.viewItem"
/> />
</div> </div>
</div>
<n8n-loading :loading="true" :rows="1" variant="p" :class="$style.itemSkeleton" v-else /> <n8n-loading :loading="true" :rows="1" variant="p" :class="$style.itemSkeleton" v-else />
</div> </div>
</div> </div>

View file

@ -7,6 +7,7 @@ import {
mockNodeCreateElement, mockNodeCreateElement,
mockActionCreateElement, mockActionCreateElement,
mockViewCreateElement, mockViewCreateElement,
mockSectionCreateElement,
} from './utils'; } from './utils';
import ItemsRenderer from '../Renderers/ItemsRenderer.vue'; import ItemsRenderer from '../Renderers/ItemsRenderer.vue';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
@ -18,13 +19,29 @@ describe('ItemsRenderer', () => {
const items = [ const items = [
mockSubcategoryCreateElement({ title: 'Subcategory 1' }), mockSubcategoryCreateElement({ title: 'Subcategory 1' }),
mockLabelCreateElement('subcategory', { key: 'label1' }), mockLabelCreateElement('subcategory', { key: 'label1' }),
mockNodeCreateElement('subcategory', { displayName: 'Node 1', name: 'node1' }), mockNodeCreateElement(
mockNodeCreateElement('subcategory', { displayName: 'Node 2', name: 'node2' }), { subcategory: 'subcategory' },
mockNodeCreateElement('subcategory', { displayName: 'Node 3', name: 'node3' }), { 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' }), mockLabelCreateElement('subcategory', { key: 'label2' }),
mockNodeCreateElement('subcategory', { displayName: 'Node 2', name: 'node2' }), mockNodeCreateElement(
mockNodeCreateElement('subcategory', { displayName: 'Node 3', name: 'node3' }), { subcategory: 'subcategory' },
{ displayName: 'Node 2', name: 'node2' },
),
mockNodeCreateElement(
{ subcategory: 'subcategory' },
{ displayName: 'Node 3', name: 'node3' },
),
mockSubcategoryCreateElement({ title: 'Subcategory 2' }), mockSubcategoryCreateElement({ title: 'Subcategory 2' }),
mockSectionCreateElement(),
]; ];
const { container } = renderComponent({ const { container } = renderComponent({
@ -40,8 +57,10 @@ describe('ItemsRenderer', () => {
const nodeItems = container.querySelectorAll('.iteratorItem .nodeItem'); const nodeItems = container.querySelectorAll('.iteratorItem .nodeItem');
const labels = container.querySelectorAll('.iteratorItem .label'); const labels = container.querySelectorAll('.iteratorItem .label');
const subCategories = container.querySelectorAll('.iteratorItem .subCategory'); 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(labels.length).toBe(2);
expect(subCategories.length).toBe(2); expect(subCategories.length).toBe(2);
}); });

View file

@ -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]);
});
});
});

View file

@ -9,6 +9,7 @@ import type {
ViewCreateElement, ViewCreateElement,
LabelCreateElement, LabelCreateElement,
ActionCreateElement, ActionCreateElement,
SectionCreateElement,
} from '@/Interface'; } from '@/Interface';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
@ -74,14 +75,15 @@ const mockLabelItemProps = (overrides?: Partial<LabelItemProps>): LabelItemProps
}); });
export const mockNodeCreateElement = ( export const mockNodeCreateElement = (
subcategory?: string, overrides?: Partial<NodeCreateElement>,
overrides?: Partial<SimplifiedNodeType>, nodeTypeOverrides?: Partial<SimplifiedNodeType>,
): NodeCreateElement => ({ ): NodeCreateElement => ({
uuid: uuidv4(), uuid: uuidv4(),
key: uuidv4(), key: uuidv4(),
type: 'node', type: 'node',
subcategory: subcategory || 'sampleSubcategory', subcategory: 'sampleSubcategory',
properties: mockSimplifiedNodeType(overrides), properties: mockSimplifiedNodeType(nodeTypeOverrides),
...overrides,
}); });
export const mockSubcategoryCreateElement = ( export const mockSubcategoryCreateElement = (
@ -93,6 +95,17 @@ export const mockSubcategoryCreateElement = (
properties: mockSubcategoryItemProps(overrides), properties: mockSubcategoryItemProps(overrides),
}); });
export const mockSectionCreateElement = (
overrides?: Partial<SectionCreateElement>,
): SectionCreateElement => ({
uuid: uuidv4(),
key: 'popular',
type: 'section',
title: 'Popular',
children: [mockNodeCreateElement(), mockNodeCreateElement()],
...overrides,
});
export const mockViewCreateElement = ( export const mockViewCreateElement = (
overrides?: Partial<ViewCreateElement>, overrides?: Partial<ViewCreateElement>,
): ViewCreateElement => ({ ): ViewCreateElement => ({

View file

@ -1,33 +1,32 @@
import { computed, nextTick, ref } from 'vue'; import type { INodeCreateElement, NodeFilterType, SimplifiedNodeType } from '@/Interface';
import { defineStore } from 'pinia';
import { v4 as uuid } from 'uuid';
import type {
NodeConnectionType,
INodeCreateElement,
NodeFilterType,
SimplifiedNodeType,
} from '@/Interface';
import { import {
AI_CODE_NODE_TYPE, AI_CODE_NODE_TYPE,
AI_OTHERS_NODE_CREATOR_VIEW, AI_OTHERS_NODE_CREATOR_VIEW,
DEFAULT_SUBCATEGORY, DEFAULT_SUBCATEGORY,
TRIGGER_NODE_CREATOR_VIEW, TRIGGER_NODE_CREATOR_VIEW,
} from '@/constants'; } 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 { useNodeCreatorStore } from '@/stores/nodeCreator.store';
import { useKeyboardNavigation } from './useKeyboardNavigation';
import { import {
transformNodeType, flattenCreateElements,
subcategorizeItems, groupItemsInSections,
sortNodeCreateElements,
searchNodes, searchNodes,
sortNodeCreateElements,
subcategorizeItems,
transformNodeType,
} from '../utils'; } from '../utils';
import { useI18n } from '@/composables/useI18n';
import type { INodeInputFilter } from 'n8n-workflow'; 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 { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { AINodesView, type NodeViewItem } from '@/components/Node/NodeCreator/viewsData'; import type { INodeInputFilter, NodeConnectionType } from 'n8n-workflow';
interface ViewStack { interface ViewStack {
uuid?: string; uuid?: string;
@ -55,6 +54,7 @@ interface ViewStack {
baseFilter?: (item: INodeCreateElement) => boolean; baseFilter?: (item: INodeCreateElement) => boolean;
itemsMapper?: (item: INodeCreateElement) => INodeCreateElement; itemsMapper?: (item: INodeCreateElement) => INodeCreateElement;
panelClass?: string; panelClass?: string;
sections?: NodeViewItemSection[];
} }
export const useViewStacks = defineStore('nodeCreatorViewStacks', () => { export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
@ -72,7 +72,9 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
if (stack.search && searchBaseItems.value) { if (stack.search && searchBaseItems.value) {
const searchBase = 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)); return extendItemsWithUUID(searchNodes(stack.search || '', searchBase));
} }
@ -83,10 +85,12 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
const stack = viewStacks.value[viewStacks.value.length - 1]; const stack = viewStacks.value[viewStacks.value.length - 1];
if (!stack) return {}; if (!stack) return {};
const flatBaselineItems = flattenCreateElements(stack.baselineItems ?? []);
return { return {
...stack, ...stack,
items: activeStackItems.value, 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( async function gotoCompatibleConnectionView(
connectionType: NodeConnectionType, connectionType: NodeConnectionType,
isOutput?: boolean, isOutput?: boolean,
@ -182,10 +188,19 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
const stack = viewStacks.value[viewStacks.value.length - 1]; const stack = viewStacks.value[viewStacks.value.length - 1];
if (!stack || !activeViewStack.value.uuid) return; if (!stack || !activeViewStack.value.uuid) return;
let stackItems = let stackItems = stack?.items ?? [];
stack?.items ??
subcategorizeItems(nodeCreatorStore.mergedNodes)[stack?.subcategory ?? DEFAULT_SUBCATEGORY] ?? 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, // Ensure that the nodes specified in `stack.forceIncludeNodes` are always included,
// regardless of whether the subcategory is matched // regardless of whether the subcategory is matched

View file

@ -4,10 +4,14 @@ import type {
SubcategorizedNodeTypes, SubcategorizedNodeTypes,
SimplifiedNodeType, SimplifiedNodeType,
INodeCreateElement, INodeCreateElement,
SectionCreateElement,
} from '@/Interface'; } from '@/Interface';
import { AI_SUBCATEGORY, CORE_NODES_CATEGORY, DEFAULT_SUBCATEGORY } from '@/constants'; import { AI_SUBCATEGORY, CORE_NODES_CATEGORY, DEFAULT_SUBCATEGORY } from '@/constants';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { sublimeSearch } from '@/utils/sortUtils'; import { sublimeSearch } from '@/utils/sortUtils';
import { i18n } from '@/plugins/i18n';
import type { NodeViewItemSection } from './viewsData';
export function transformNodeType( export function transformNodeType(
node: SimplifiedNodeType, node: SimplifiedNodeType,
@ -75,3 +79,42 @@ export function searchNodes(searchFilter: string, items: INodeCreateElement[]) {
return result; 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<string, INodeCreateElement[]>, 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;
}

View file

@ -36,6 +36,12 @@ import type { SimplifiedNodeType } from '@/Interface';
import type { INodeTypeDescription } from 'n8n-workflow'; import type { INodeTypeDescription } from 'n8n-workflow';
import { NodeConnectionType } from 'n8n-workflow'; import { NodeConnectionType } from 'n8n-workflow';
export interface NodeViewItemSection {
key: string;
title: string;
items: string[];
}
export interface NodeViewItem { export interface NodeViewItem {
key: string; key: string;
type: string; type: string;
@ -49,6 +55,7 @@ export interface NodeViewItem {
connectionType?: NodeConnectionType; connectionType?: NodeConnectionType;
panelClass?: string; panelClass?: string;
group?: string[]; group?: string[];
sections?: NodeViewItemSection[];
description?: string; description?: string;
forceIncludeNodes?: string[]; forceIncludeNodes?: string[];
}; };
@ -342,6 +349,13 @@ export function RegularView(nodes: SimplifiedNodeType[]) {
properties: { properties: {
title: TRANSFORM_DATA_SUBCATEGORY, title: TRANSFORM_DATA_SUBCATEGORY,
icon: 'pen', icon: 'pen',
sections: [
{
key: 'popular',
title: i18n.baseText('nodeCreator.sectionNames.popular'),
items: [],
},
],
}, },
}, },
{ {

View file

@ -898,6 +898,8 @@
"nodeCreator.subcategoryNames.tools": "Tools", "nodeCreator.subcategoryNames.tools": "Tools",
"nodeCreator.subcategoryNames.vectorStores": "Vector Stores", "nodeCreator.subcategoryNames.vectorStores": "Vector Stores",
"nodeCreator.subcategoryNames.miscellaneous": "Miscellaneous", "nodeCreator.subcategoryNames.miscellaneous": "Miscellaneous",
"nodeCreator.sectionNames.popular": "Popular",
"nodeCreator.sectionNames.other": "Other",
"nodeCreator.triggerHelperPanel.addAnotherTrigger": "Add another trigger", "nodeCreator.triggerHelperPanel.addAnotherTrigger": "Add another trigger",
"nodeCreator.triggerHelperPanel.addAnotherTriggerDescription": "Triggers start your workflow. Workflows can have multiple triggers.", "nodeCreator.triggerHelperPanel.addAnotherTriggerDescription": "Triggers start your workflow. Workflows can have multiple triggers.",
"nodeCreator.triggerHelperPanel.title": "When should this workflow run?", "nodeCreator.triggerHelperPanel.title": "When should this workflow run?",