mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-26 05:04:05 -08:00
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:
parent
485a0c73cb
commit
39fa8d21bb
|
@ -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');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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(() => {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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 => ({
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -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?",
|
||||||
|
|
Loading…
Reference in a new issue