feat(editor): Enhance Node Creator actions view (#5954)

* WIP

* WIP

* Extract actions into composable

* WIP: Preserve categories when searching

* WIP

* WIP: Tweak styles

* WIP: Refactor node creator

* WIP: Finish Node Creator node view/subcategories refactor

* WIP: Finished actions refactor

* Cleanup & Lintfix

* WIP: Improve memory managment

* Fix interactions

* WIP

* WIP: Keyboard navigation

* Improve keyboard navigation and memory managment

* Finished view refactor

* FIx custom api calls and activation callouts

* Fix actions tracking and cleanup

* Product review fixes

* Telemetry fixes

* Fix node creator e2es

* Set action name font size and actionsEmpty font weight

* Fix failing credentials spec

* Make sure to select first action item when switching from nodes panel to actions panel

* Add actions panel e2e tests

* Cleanup

* Fix actions generation and cleanup

* Add correct Learn More link and adjust displaying of trigger icon

* Change trigger icon condition to use nodeType group

* Cleanup nodeTypesUtils and snapshots and lintfixes

* Lint fixes

* Refine logic to show trigger icon in node creator

* Add unit tests & clean up

* Add `003_auto_insert_action` experiment, hide empty sections for opposite root view

* Lintfix

* Do not show empty category tooltips and only show activation callout in triger root view

* Fix no-results node creator view

* Spacings tweaks and root rendering logic adjustment

* Add unit tests

* Lint and e2e fixes

* Revert CLI changes, fix unit tests

* Remove useless comments

* Sync master, replace $externalHooks mixin

* Lint fix

* Focus first action when panel slides in, not category

* Address PR comments

* Lint fix

* Remove `setAddedNodeActionParameters` optional track param

* Further simplify setAddedNodeActionParameters

* Fix pnpn lock file

* Fix types imports

* Fix 13-pinning spec
This commit is contained in:
OlegIvaniv 2023-04-26 09:18:10 +02:00 committed by GitHub
parent 6335e0938d
commit 390841bbf0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
54 changed files with 3489 additions and 2450 deletions

View file

@ -69,6 +69,6 @@ describe('Inline expression editor', () => {
WorkflowPage.getters.inlineExpressionEditorInput().clear();
WorkflowPage.getters.inlineExpressionEditorInput().type('{{');
WorkflowPage.getters.inlineExpressionEditorInput().type('$parameter["operation"]');
WorkflowPage.getters.inlineExpressionEditorOutput().contains(/^getAll$/);
WorkflowPage.getters.inlineExpressionEditorOutput().contains(/^get$/);
});
});

View file

@ -1,6 +1,6 @@
import {
HTTP_REQUEST_NODE_NAME,
MANUAL_TRIGGER_NODE_DISPLAY_NAME,
MANUAL_TRIGGER_NODE_NAME,
PIPEDRIVE_NODE_NAME,
SET_NODE_NAME,
} from '../constants';
@ -75,7 +75,7 @@ describe('Data pinning', () => {
});
it('Should be able to reference paired items in a node located before pinned data', () => {
workflowPage.actions.addInitialNodeToCanvas(MANUAL_TRIGGER_NODE_DISPLAY_NAME);
workflowPage.actions.addInitialNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
workflowPage.actions.addNodeToCanvas(HTTP_REQUEST_NODE_NAME, true, true);
ndv.actions.setPinnedData([{ http: 123 }]);
ndv.actions.close();

View file

@ -58,8 +58,8 @@ describe('Node Creator', () => {
nodeCreatorFeature.getters.getCreatorItem('On app event').click();
nodeCreatorFeature.getters.searchBar().find('input').clear().type('edit image');
nodeCreatorFeature.getters.getCreatorItem('Results in other categories (1)').should('exist');
nodeCreatorFeature.getters.creatorItem().should('have.length', 2);
nodeCreatorFeature.getters.getCategoryItem('Results in other categories').should('exist');
nodeCreatorFeature.getters.creatorItem().should('have.length', 1);
nodeCreatorFeature.getters.getCreatorItem('Edit Image').should('exist');
nodeCreatorFeature.getters.searchBar().find('input').clear().type('edit image123123');
nodeCreatorFeature.getters.creatorItem().should('have.length', 0);
@ -101,7 +101,7 @@ describe('Node Creator', () => {
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('{downarrow} {downarrow} {downarrow} {rightarrow}');
nodeCreatorFeature.getters.searchBar().find('input').type('{uparrow}{uparrow}{rightarrow}');
NDVModal.getters.parameterInput('operation').should('contain.text', 'Rename');
})
@ -127,9 +127,107 @@ describe('Node Creator', () => {
})
nodeCreatorFeature.getters.searchBar().find('input').clear().type(doubleActionNode);
nodeCreatorFeature.getters.getCreatorItem(doubleActionNode).click();
nodeCreatorFeature.getters.creatorItem().should('have.length', 2);
nodeCreatorFeature.getters.creatorItem().should('have.length', 4);
})
it('should have "Actions" section collapsed when opening actions view from Trigger root view', () => {
nodeCreatorFeature.actions.openNodeCreator();
nodeCreatorFeature.getters.searchBar().find('input').clear().type('ActiveCampaign');
nodeCreatorFeature.getters.getCreatorItem('ActiveCampaign').click();
nodeCreatorFeature.getters.getCategoryItem('Actions').should('exist');
nodeCreatorFeature.getters.getCategoryItem('Triggers').should('exist');
nodeCreatorFeature.getters.getCategoryItem('Triggers').parent().should('not.have.attr', 'data-category-collapsed');
nodeCreatorFeature.getters.getCategoryItem('Actions').parent().should('have.attr', 'data-category-collapsed', 'true');
nodeCreatorFeature.getters.getCategoryItem('Actions').click()
nodeCreatorFeature.getters.getCategoryItem('Actions').parent().should('not.have.attr', 'data-category-collapsed');
});
it('should have "Triggers" section collapsed when opening actions view from Regular root view', () => {
nodeCreatorFeature.actions.openNodeCreator();
nodeCreatorFeature.getters.getCreatorItem('Manually').click();
nodeCreatorFeature.actions.openNodeCreator();
nodeCreatorFeature.getters.searchBar().find('input').clear().type('n8n');
nodeCreatorFeature.getters.getCreatorItem('n8n').click();
nodeCreatorFeature.getters.getCategoryItem('Actions').parent().should('not.have.attr', 'data-category-collapsed');
nodeCreatorFeature.getters.getCategoryItem('Actions').click()
nodeCreatorFeature.getters.getCategoryItem('Actions').parent().should('have.attr', 'data-category-collapsed');
nodeCreatorFeature.getters.getCategoryItem('Triggers').parent().should('have.attr', 'data-category-collapsed');
nodeCreatorFeature.getters.getCategoryItem('Triggers').click()
nodeCreatorFeature.getters.getCategoryItem('Triggers').parent().should('not.have.attr', 'data-category-collapsed');
});
it('should show callout and two suggested nodes if node has no trigger actions', () => {
nodeCreatorFeature.actions.openNodeCreator();
nodeCreatorFeature.getters.searchBar().find('input').clear().type('Customer Datastore (n8n training)');
nodeCreatorFeature.getters.getCreatorItem('Customer Datastore (n8n training)').click();
cy.getByTestId('actions-panel-no-triggers-callout').should('be.visible');
nodeCreatorFeature.getters.getCreatorItem('On a Schedule').should('be.visible');
nodeCreatorFeature.getters.getCreatorItem('On a Webhook call').should('be.visible');
});
it('should show intro callout if user has not made a production execution', () => {
nodeCreatorFeature.actions.openNodeCreator();
nodeCreatorFeature.getters.searchBar().find('input').clear().type('Customer Datastore (n8n training)');
nodeCreatorFeature.getters.getCreatorItem('Customer Datastore (n8n training)').click();
cy.getByTestId('actions-panel-activation-callout').should('be.visible');
nodeCreatorFeature.getters.activeSubcategory().find('button').click();
nodeCreatorFeature.getters.searchBar().find('input').clear()
nodeCreatorFeature.getters.getCreatorItem('On a schedule').click();
// Setup 1s interval execution
cy.getByTestId('parameter-input-field').click();
cy.getByTestId('parameter-input-field')
.find('.el-select-dropdown')
.find('.option-headline')
.contains('Seconds')
.click();
cy.getByTestId('parameter-input-secondsInterval').clear().type('1');
NDVModal.actions.close();
nodeCreatorFeature.actions.openNodeCreator();
nodeCreatorFeature.getters.searchBar().find('input').clear().type('Customer Datastore (n8n training)');
nodeCreatorFeature.getters.getCreatorItem('Customer Datastore (n8n training)').click();
nodeCreatorFeature.getters.getCreatorItem('Get All People').click();
NDVModal.actions.close();
WorkflowPage.actions.saveWorkflowOnButtonClick();
WorkflowPage.actions.activateWorkflow();
WorkflowPage.getters.activatorSwitch().should('have.class', 'is-checked');
// Wait for schedule 1s execution to mark user as having made a production execution
cy.wait(1500);
cy.reload()
// Action callout should not be visible after user has made a production execution
nodeCreatorFeature.actions.openNodeCreator();
nodeCreatorFeature.getters.searchBar().find('input').clear().type('Customer Datastore (n8n training)');
nodeCreatorFeature.getters.getCreatorItem('Customer Datastore (n8n training)').click();
cy.getByTestId('actions-panel-activation-callout').should('not.exist');
});
it('should show Trigger and Actions sections during search', () => {
nodeCreatorFeature.actions.openNodeCreator();
nodeCreatorFeature.getters.searchBar().find('input').clear().type('Customer Datastore (n8n training)');
nodeCreatorFeature.getters.getCreatorItem('Customer Datastore (n8n training)').click();
nodeCreatorFeature.getters.searchBar().find('input').clear().type('Non existent action name');
nodeCreatorFeature.getters.getCategoryItem('Triggers').should('be.visible');
nodeCreatorFeature.getters.getCategoryItem('Actions').should('be.visible');
cy.getByTestId('actions-panel-no-triggers-callout').should('be.visible');
nodeCreatorFeature.getters.getCreatorItem('On a Schedule').should('be.visible');
nodeCreatorFeature.getters.getCreatorItem('On a Webhook call').should('be.visible');
});
describe('should correctly append manual trigger for regular actions', () => {
// For these sources, manual node should be added
const sourcesWithAppend = [
@ -152,6 +250,7 @@ describe('Node Creator', () => {
source.handler()
nodeCreatorFeature.getters.searchBar().find('input').clear().type('n8n');
nodeCreatorFeature.getters.getCreatorItem('n8n').click();
nodeCreatorFeature.getters.getCategoryItem('Actions').click();
nodeCreatorFeature.getters.getCreatorItem('Create a credential').click();
NDVModal.actions.close();
WorkflowPage.getters.canvasNodes().should('have.length', 2);
@ -162,12 +261,14 @@ describe('Node Creator', () => {
nodeCreatorFeature.getters.canvasAddButton().click();
nodeCreatorFeature.getters.searchBar().find('input').clear().type('n8n');
nodeCreatorFeature.getters.getCreatorItem('n8n').click();
nodeCreatorFeature.getters.getCategoryItem('Actions').click();
nodeCreatorFeature.getters.getCreatorItem('Create a credential').click();
NDVModal.actions.close();
WorkflowPage.actions.deleteNode('When clicking "Execute Workflow"')
WorkflowPage.getters.canvasNodePlusEndpointByName('n8n').click()
nodeCreatorFeature.getters.searchBar().find('input').clear().type('n8n');
nodeCreatorFeature.getters.getCreatorItem('n8n').click();
nodeCreatorFeature.getters.getCategoryItem('Actions').click();
nodeCreatorFeature.getters.getCreatorItem('Create a credential').click();
NDVModal.actions.close();
WorkflowPage.getters.canvasNodes().should('have.length', 2);

View file

@ -60,6 +60,6 @@ describe('Expression editor modal', () => {
it('should resolve $parameter[]', () => {
WorkflowPage.getters.expressionModalInput().clear();
WorkflowPage.getters.expressionModalInput().type('{{ $parameter["operation"]');
WorkflowPage.getters.expressionModalOutput().contains(/^getAll$/);
WorkflowPage.getters.expressionModalOutput().contains(/^get$/);
});
});

View file

@ -7,6 +7,7 @@ export class NodeCreator extends BasePage {
plusButton: () => cy.getByTestId('node-creator-plus-button'),
canvasAddButton: () => cy.getByTestId('canvas-add-button'),
searchBar: () => cy.getByTestId('search-bar'),
getCategoryItem: (label: string) => cy.get(`[data-keyboard-nav-id="${label}"]`),
getCreatorItem: (label: string) =>
this.getters.creatorItem().contains(label).parents('[data-test-id="item-iterator-item"]'),
getNthCreatorItem: (n: number) => this.getters.creatorItem().eq(n),
@ -15,10 +16,11 @@ export class NodeCreator extends BasePage {
selectedTab: () => this.getters.nodeCreatorTabs().find('.is-active'),
categorizedItems: () => cy.getByTestId('categorized-items'),
creatorItem: () => cy.getByTestId('item-iterator-item'),
categoryItem: () => cy.getByTestId('node-creator-category-item'),
communityNodeTooltip: () => cy.getByTestId('node-item-community-tooltip'),
noResults: () => cy.getByTestId('categorized-no-results'),
noResults: () => cy.getByTestId('node-creator-no-results'),
nodeItemName: () => cy.getByTestId('node-creator-item-name'),
activeSubcategory: () => cy.getByTestId('categorized-items-subcategory'),
activeSubcategory: () => cy.getByTestId('nodes-list-header'),
expandedCategories: () =>
this.getters.creatorItem().find('>div').filter('.active').invoke('text'),
};

View file

@ -140,7 +140,8 @@ export class WorkflowPage extends BasePage {
if(action) {
cy.contains(action).click()
} else {
cy.getByTestId('item-iterator-item').eq(1).click()
// Select the first action
cy.get('[data-keyboard-nav-type="action"]').eq(0).click()
}
}
})

View file

@ -1,7 +1,7 @@
<template>
<div :class="classes" role="alert">
<div :class="$style['message-section']">
<div :class="$style.icon">
<div :class="$style.messageSection">
<div :class="$style.icon" v-if="!iconless">
<n8n-icon :icon="getIcon" :size="theme === 'secondary' ? 'medium' : 'large'" />
</div>
<n8n-text size="small">
@ -44,10 +44,21 @@ export default defineComponent({
type: String,
default: 'info-circle',
},
iconless: {
type: Boolean,
},
slim: {
type: Boolean,
},
},
computed: {
classes(): string[] {
return ['n8n-callout', this.$style.callout, this.$style[this.theme]];
return [
'n8n-callout',
this.$style.callout,
this.$style[this.theme],
this.slim ? this.$style.slim : '',
];
},
getIcon(): string {
if (Object.keys(CALLOUT_DEFAULT_ICONS).includes(this.theme)) {
@ -70,9 +81,14 @@ export default defineComponent({
border-radius: var(--border-radius-base);
align-items: center;
line-height: var(--font-line-height-loose);
&.slim {
line-height: var(--font-line-height-loose);
padding: var(--spacing-3xs) var(--spacing-2xs);
}
}
.message-section {
.messageSection {
display: flex;
align-items: center;
}

View file

@ -2,7 +2,7 @@
exports[`components > N8nCallout > should render additional slots correctly 1`] = `
"<div role=\\"alert\\" class=\\"n8n-callout callout custom\\">
<div class=\\"message-section\\">
<div class=\\"messageSection\\">
<div class=\\"icon\\">
<n8n-icon-stub icon=\\"code-branch\\" size=\\"large\\"></n8n-icon-stub>
</div>
@ -16,7 +16,7 @@ exports[`components > N8nCallout > should render additional slots correctly 1`]
exports[`components > N8nCallout > should render custom theme correctly 1`] = `
"<div role=\\"alert\\" class=\\"n8n-callout callout custom\\">
<div class=\\"message-section\\">
<div class=\\"messageSection\\">
<div class=\\"icon\\">
<n8n-icon-stub icon=\\"code-branch\\" size=\\"large\\"></n8n-icon-stub>
</div>
@ -29,7 +29,7 @@ exports[`components > N8nCallout > should render custom theme correctly 1`] = `
exports[`components > N8nCallout > should render danger theme correctly 1`] = `
"<div role=\\"alert\\" class=\\"n8n-callout callout danger\\">
<div class=\\"message-section\\">
<div class=\\"messageSection\\">
<div class=\\"icon\\">
<n8n-icon-stub icon=\\"times-circle\\" size=\\"large\\"></n8n-icon-stub>
</div>
@ -42,7 +42,7 @@ exports[`components > N8nCallout > should render danger theme correctly 1`] = `
exports[`components > N8nCallout > should render info theme correctly 1`] = `
"<div role=\\"alert\\" class=\\"n8n-callout callout info\\">
<div class=\\"message-section\\">
<div class=\\"messageSection\\">
<div class=\\"icon\\">
<n8n-icon-stub icon=\\"info-circle\\" size=\\"large\\"></n8n-icon-stub>
</div>
@ -55,7 +55,7 @@ exports[`components > N8nCallout > should render info theme correctly 1`] = `
exports[`components > N8nCallout > should render secondary theme correctly 1`] = `
"<div role=\\"alert\\" class=\\"n8n-callout callout secondary\\">
<div class=\\"message-section\\">
<div class=\\"messageSection\\">
<div class=\\"icon\\">
<n8n-icon-stub icon=\\"info-circle\\" size=\\"medium\\"></n8n-icon-stub>
</div>
@ -68,7 +68,7 @@ exports[`components > N8nCallout > should render secondary theme correctly 1`] =
exports[`components > N8nCallout > should render success theme correctly 1`] = `
"<div role=\\"alert\\" class=\\"n8n-callout callout success\\">
<div class=\\"message-section\\">
<div class=\\"messageSection\\">
<div class=\\"icon\\">
<n8n-icon-stub icon=\\"check-circle\\" size=\\"large\\"></n8n-icon-stub>
</div>
@ -81,7 +81,7 @@ exports[`components > N8nCallout > should render success theme correctly 1`] = `
exports[`components > N8nCallout > should render warning theme correctly 1`] = `
"<div role=\\"alert\\" class=\\"n8n-callout callout warning\\">
<div class=\\"message-section\\">
<div class=\\"messageSection\\">
<div class=\\"icon\\">
<n8n-icon-stub icon=\\"exclamation-triangle\\" size=\\"large\\"></n8n-icon-stub>
</div>

View file

@ -92,16 +92,11 @@ defineEmits<{
}
.nodeIcon {
display: flex;
margin-right: var(--spacing-s);
& > :global(*) {
min-width: 25px;
max-width: 25px;
}
margin-right: var(--node-icon-margin-right, var(--spacing-s));
}
.name {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-s);
font-weight: var(--node-creator-name-weight, var(--font-weight-bold));
font-size: var(--node-creator-name-size, var(--font-size-s));
line-height: 1.115rem;
}
.description {

View file

@ -14,7 +14,7 @@
<template #content>{{ nodeTypeName }}</template>
<div v-if="type !== 'unknown'" :class="$style.icon">
<img v-if="type === 'file'" :src="src" :class="$style.nodeIconImage" />
<font-awesome-icon v-else :icon="name" :style="fontStyleData" />
<font-awesome-icon v-else :icon="name" :class="$style.iconFa" :style="fontStyleData" />
</div>
<div v-else :class="$style.nodeIconPlaceholder">
{{ nodeTypeName ? nodeTypeName.charAt(0) : '?' }}
@ -127,6 +127,11 @@ export default defineComponent({
justify-content: center;
align-items: center;
pointer-events: none;
svg {
max-width: 100%;
max-height: 100%;
}
}
.nodeIconPlaceholder {
text-align: center;

View file

@ -39,7 +39,7 @@
"@fortawesome/fontawesome-svg-core": "^1.2.35",
"@fortawesome/free-regular-svg-icons": "^6.1.1",
"@fortawesome/free-solid-svg-icons": "^5.15.3",
"@fortawesome/vue-fontawesome": "^2.0.2",
"@fortawesome/vue-fontawesome": "^2.0.10",
"@jsplumb/browser-ui": "^5.13.2",
"@jsplumb/common": "^5.13.2",
"@jsplumb/connector-bezier": "^5.13.2",
@ -105,6 +105,7 @@
"@vitejs/plugin-legacy": "^3.0.1",
"@vitejs/plugin-vue2": "^2.2.0",
"@vitest/coverage-c8": "^0.28.5",
"@volar-plugins/eslint": "^0.0.4",
"c8": "^7.12.0",
"jshint": "^2.9.7",
"miragejs": "^0.1.47",

View file

@ -25,7 +25,6 @@ import type {
INodeCredentials,
INodeListSearchItems,
NodeParameterValueType,
INodeActionTypeDescription,
IDisplayOptions,
IExecutionsSummary,
FeatureFlags,
@ -36,7 +35,11 @@ import type {
WorkflowSettings,
} from 'n8n-workflow';
import type { SignInType } from './constants';
import type { FAKE_DOOR_FEATURES, TRIGGER_NODE_FILTER, REGULAR_NODE_FILTER } from './constants';
import type {
FAKE_DOOR_FEATURES,
TRIGGER_NODE_CREATOR_VIEW,
REGULAR_NODE_CREATOR_VIEW,
} from './constants';
import type { BulkCommand, Undoable } from '@/models/history';
import type { PartialBy } from '@/utils/typeHelpers';
@ -707,67 +710,85 @@ export interface ITimeoutHMS {
export type WorkflowTitleStatus = 'EXECUTING' | 'IDLE' | 'ERROR';
export interface ISubcategoryItemProps {
subcategory: string;
description: string;
key?: string;
iconType: string;
export type ExtractActionKeys<T> = T extends SimplifiedNodeType ? T['name'] : never;
export type ActionsRecord<T extends SimplifiedNodeType[]> = {
[K in ExtractActionKeys<T[number]>]: ActionTypeDescription[];
};
export interface SimplifiedNodeType
extends Pick<
INodeTypeDescription,
'displayName' | 'description' | 'name' | 'group' | 'icon' | 'iconUrl' | 'codex' | 'defaults'
> {}
export interface SubcategoryItemProps {
description?: string;
iconType?: string;
icon?: string;
title?: string;
subcategory?: string;
defaults?: INodeParameters;
forceIncludeNodes?: string[];
}
export interface ViewItemProps {
withTopBorder: boolean;
title: string;
description: string;
icon: string;
}
export interface INodeItemProps {
subcategory: string;
nodeType: INodeTypeDescription;
export interface LabelItemProps {
key: string;
}
export interface ActionTypeDescription extends SimplifiedNodeType {
displayOptions?: IDisplayOptions;
values?: IDataObject;
actionKey: string;
codex: {
label: string;
categories: string[];
};
}
export interface IActionItemProps {
subcategory: string;
nodeType: INodeActionTypeDescription;
}
export interface ICategoryItemProps {
expanded: boolean;
category: string;
export interface CategoryItemProps {
name: string;
count: number;
}
export interface CreateElementBase {
uuid?: string;
key: string;
includedByTrigger?: boolean;
includedByRegular?: boolean;
}
export interface NodeCreateElement extends CreateElementBase {
type: 'node';
category?: string[];
properties: INodeItemProps;
subcategory: string;
properties: SimplifiedNodeType;
}
export interface CategoryCreateElement extends CreateElementBase {
type: 'category';
properties: ICategoryItemProps;
subcategory: string;
properties: CategoryItemProps;
}
export interface SubcategoryCreateElement extends CreateElementBase {
type: 'subcategory';
properties: ISubcategoryItemProps;
properties: SubcategoryItemProps;
}
export interface ViewCreateElement extends CreateElementBase {
type: 'view';
properties: ViewItemProps;
}
export interface LabelCreateElement extends CreateElementBase {
type: 'label';
subcategory: string;
properties: LabelItemProps;
}
export interface ActionCreateElement extends CreateElementBase {
type: 'action';
category: string;
properties: IActionItemProps;
subcategory: string;
properties: ActionTypeDescription;
}
export type INodeCreateElement =
@ -775,18 +796,12 @@ export type INodeCreateElement =
| CategoryCreateElement
| SubcategoryCreateElement
| ViewCreateElement
| LabelCreateElement
| ActionCreateElement;
export interface ICategoriesWithNodes {
[category: string]: {
[subcategory: string]: {
regularCount: number;
triggerCount: number;
nodes: INodeCreateElement[];
};
};
export interface SubcategorizedNodeTypes {
[subcategory: string]: INodeCreateElement[];
}
export interface ITag {
id: string;
name: string;
@ -1072,7 +1087,7 @@ export type IFakeDoorLocation =
| 'credentialsModal'
| 'workflowShareModal';
export type INodeFilterType = typeof REGULAR_NODE_FILTER | typeof TRIGGER_NODE_FILTER;
export type NodeFilterType = typeof REGULAR_NODE_CREATOR_VIEW | typeof TRIGGER_NODE_CREATOR_VIEW;
export type NodeCreatorOpenSource =
| ''
@ -1087,8 +1102,8 @@ export type NodeCreatorOpenSource =
export interface INodeCreatorState {
itemsFilter: string;
showScrim: boolean;
rootViewHistory: INodeFilterType[];
selectedView: INodeFilterType;
rootViewHistory: NodeFilterType[];
selectedView: NodeFilterType;
openSource: NodeCreatorOpenSource;
}

View file

@ -1,690 +0,0 @@
<template>
<transition :name="`panel-slide-${state.transitionDirection}`">
<div
ref="mainPanelContainer"
tabindex="0"
data-test-id="categorized-items"
:class="$style.categorizedItems"
:key="`${activeSubcategoryTitle + selectedViewType}_transition`"
@keydown.capture="nodeFilterKeyDown"
>
<div
:class="{
[$style.header]: true,
[$style.headerWithBackground]: activeSubcategory,
}"
data-test-id="categorized-items-subcategory"
>
<button
:class="$style.backButton"
@click="onBackButton"
v-if="isViewNavigated || activeSubcategory"
>
<font-awesome-icon :class="$style.subcategoryBackIcon" icon="arrow-left" size="2x" />
</button>
<div v-if="isRootView && $slots.header">
<slot name="header" />
</div>
<template v-if="activeSubcategory">
<n8n-node-icon
:class="$style.nodeIcon"
v-if="showSubcategoryIcon && activeSubcategory.properties.icon"
:type="activeSubcategory.properties.iconType || 'unknown'"
:src="activeSubcategory.properties.icon"
:name="activeSubcategory.properties.icon"
:color="activeSubcategory.properties.color"
:circle="false"
:showTooltip="false"
:size="16"
/>
<span v-text="activeSubcategoryTitle" />
</template>
</div>
<div
v-if="isRootView && $slots.description"
:class="{
[$style.description]: true,
[$style.descriptionOffset]: isViewNavigated || activeSubcategory,
}"
>
<slot name="description" />
</div>
<search-bar
v-if="alwaysShowSearch || isSearchVisible"
:key="nodeCreatorStore.selectedView"
:value="searchFilter"
:placeholder="
searchPlaceholder
? searchPlaceholder
: $locale.baseText('nodeCreator.searchBar.searchNodes')
"
@input="onNodeFilterChange"
/>
<div :class="$style.scrollable" ref="scrollableContainer">
<item-iterator
:elements="searchFilter.length === 0 ? renderedItems : mergedFilteredNodes"
:activeIndex="activeSubcategory ? activeSubcategoryIndex : activeIndex"
:with-actions-getter="withActionsGetter"
:with-description-getter="withDescriptionGetter"
:lazyRender="true"
@selected="selected"
@actionsOpen="$listeners.actionsOpen"
@nodeTypeSelected="$listeners.nodeTypeSelected"
/>
<div v-if="searchFilter.length > 0 && mergedFilteredNodes.length === 0">
<slot name="noResults" />
</div>
<div :class="$style.footer" v-else-if="$slots.footer">
<slot name="footer" />
</div>
</div>
</div>
</transition>
</template>
<script lang="ts" setup>
import {
computed,
reactive,
watch,
getCurrentInstance,
toRefs,
ref,
onUnmounted,
nextTick,
} from 'vue';
import { camelCase } from 'lodash-es';
import type { INodeTypeDescription } from 'n8n-workflow';
import ItemIterator from './ItemIterator.vue';
import SearchBar from './SearchBar.vue';
import type {
INodeCreateElement,
ISubcategoryItemProps,
ICategoryItemProps,
SubcategoryCreateElement,
NodeCreateElement,
CategoryCreateElement,
INodeItemProps,
} from '@/Interface';
import type { BaseTextKey } from '@/plugins/i18n';
import { sublimeSearch, matchesNodeType, matchesSelectType } from '@/utils';
import { useWorkflowsStore } from '@/stores/workflows';
import { useRootStore } from '@/stores/n8nRootStore';
import { useNodeCreatorStore } from '@/stores/nodeCreator';
import { useExternalHooks } from '@/composables';
export interface Props {
showSubcategoryIcon?: boolean;
alwaysShowSearch?: boolean;
hideOtherCategoryItems?: boolean;
lazyRender?: boolean;
searchPlaceholder?: string;
withActionsGetter?: (element: NodeCreateElement) => boolean;
withDescriptionGetter?: (element: NodeCreateElement) => boolean;
searchItems?: INodeCreateElement[];
firstLevelItems?: INodeCreateElement[];
categorizedItems: INodeCreateElement[];
subcategoryOverride?: SubcategoryCreateElement | undefined;
allItems?: INodeCreateElement[];
}
const OTHER_RESULT_CATEGORY = 'searchAll';
const props = withDefaults(defineProps<Props>(), {
allItems: () => [],
searchItems: () => [],
firstLevelItems: () => [],
});
const emit = defineEmits<{
(event: 'subcategoryClose', value: INodeCreateElement[]): void;
(event: 'onSubcategorySelected', value: INodeCreateElement): void;
(event: 'nodeTypeSelected', value: string[]): void;
(event: 'actionSelected', value: INodeCreateElement): void;
(event: 'actionsOpen', value: INodeTypeDescription): void;
}>();
const instance = getCurrentInstance();
const externalHooks = useExternalHooks();
const { defaultLocale } = useRootStore();
const { workflowId } = useWorkflowsStore();
const nodeCreatorStore = useNodeCreatorStore();
const state = reactive({
activeCategories: [] as string[],
// Keep track of activated subcategories so we could traverse back more than one level
activeSubcategoryHistory: [] as Array<{
scrollPosition: number;
subcategory: INodeCreateElement;
activeIndex: number;
filter: string;
}>,
activeIndex: 0,
activeSubcategoryIndex: 0,
mainPanelContainer: null as HTMLElement | null,
transitionDirection: 'in',
});
const searchBar = ref<InstanceType<typeof SearchBar>>();
const scrollableContainer = ref<InstanceType<typeof HTMLElement>>();
const activeSubcategory = computed<INodeCreateElement | null>(() => {
return (
state.activeSubcategoryHistory[state.activeSubcategoryHistory.length - 1]?.subcategory || null
);
});
const categoriesKeys = computed(() =>
props.categorizedItems.filter((item) => item.type === 'category').map((item) => item.key),
);
const activeSubcategoryTitle = computed<string>(() => {
if (!activeSubcategory.value || !activeSubcategory.value.properties) return '';
const subcategory = (activeSubcategory.value.properties as ISubcategoryItemProps).subcategory;
const subcategoryName = camelCase(subcategory);
const titleLocaleKey = `nodeCreator.subcategoryTitles.${subcategoryName}` as BaseTextKey;
const nameLocaleKey = `nodeCreator.subcategoryNames.${subcategoryName}` as BaseTextKey;
const titleLocale = instance?.proxy?.$locale.baseText(titleLocaleKey) as string;
const nameLocale = instance?.proxy?.$locale.baseText(nameLocaleKey) as string;
// If resolved title locale is same as the locale key it means it doesn't exist
// so we fallback to the subcategoryName
if (titleLocale === titleLocaleKey)
return nameLocale === nameLocaleKey ? subcategory : nameLocale;
return titleLocale;
});
const searchFilter = computed<string>(() => nodeCreatorStore.itemsFilter.toLowerCase().trim());
const selectedViewType = computed(() => nodeCreatorStore.selectedView);
const filteredNodeTypes = computed<INodeCreateElement[]>(() => {
const filter = searchFilter.value;
let returnItems: INodeCreateElement[] = [];
if (defaultLocale !== 'en') {
returnItems = props.searchItems.filter((el: INodeCreateElement) => {
return (
filter &&
matchesSelectType(el, nodeCreatorStore.selectedView) &&
matchesNodeType(el, filter)
);
});
} else {
const matchingNodes =
subcategorizedItems.value.length > 0 ? subcategorizedItems.value : props.searchItems;
returnItems = getFilteredNodes(matchingNodes);
}
return returnItems;
});
const isViewNavigated = computed(() => nodeCreatorStore.rootViewHistory.length > 1);
const globalFilteredNodeTypes = computed<INodeCreateElement[]>(() => {
const result = getFilteredNodes(props.allItems).reduce((acc, item) => {
if (acc.find((el) => trimTriggerNodeName(el.key) === trimTriggerNodeName(item.key))) {
return acc;
}
return [...acc, item];
}, [] as INodeCreateElement[]);
return result;
});
const otherCategoryNodes = computed(() => {
const nodes = [];
// Get diff of nodes between `globalFilteredNodeTypes` and `filteredNodeTypes`
for (const node of globalFilteredNodeTypes.value) {
const isNodeInFiltered = filteredNodeTypes.value.find(
(el) => trimTriggerNodeName(el.key) === trimTriggerNodeName(node.key),
);
if (!isNodeInFiltered) nodes.push(node);
}
return nodes;
});
const mergedFilteredNodes = computed<INodeCreateElement[]>(() => {
if (props.hideOtherCategoryItems) return filteredNodeTypes.value;
const isExpanded = state.activeCategories.includes(OTHER_RESULT_CATEGORY);
const searchCategory: CategoryCreateElement = {
type: 'category',
key: OTHER_RESULT_CATEGORY,
properties: {
category: OTHER_RESULT_CATEGORY,
name: `Results in other categories (${otherCategoryNodes.value.length})`,
expanded: isExpanded,
},
};
const nodeTypes = [...filteredNodeTypes.value];
if (otherCategoryNodes.value.length > 0) {
nodeTypes.push(searchCategory);
}
if (isExpanded) {
nodeTypes.push(...otherCategoryNodes.value);
}
return nodeTypes;
});
const isRootView = computed(() => activeSubcategory.value === null);
const subcategorizedItems = computed<INodeCreateElement[]>(() => {
if (!activeSubcategory.value) return [];
const items = props.searchItems.filter((el: INodeCreateElement) => {
if (!activeSubcategory.value) return false;
const subcategories = Object.values(
(el.properties as INodeItemProps).nodeType.codex?.subcategories || {},
).flat();
return subcategories.includes(activeSubcategory.value.key);
});
return items.filter((el: INodeCreateElement) =>
matchesSelectType(el, nodeCreatorStore.selectedView),
);
});
const filteredCategorizedItems = computed<INodeCreateElement[]>(() => {
let categoriesCount = 0;
const reducedItems = props.categorizedItems.reduce(
(acc: INodeCreateElement[], el: INodeCreateElement) => {
if (el.type === 'category') {
el.properties.expanded = state.activeCategories.includes(el.key);
categoriesCount++;
return [...acc, el];
}
if (el.type === 'action' && state.activeCategories.includes(el.category)) {
return [...acc, el];
}
return acc;
},
[],
);
// If there is only one category we don't show it
if (categoriesCount <= 1)
return reducedItems.filter((el: INodeCreateElement) => el.type !== 'category');
return reducedItems;
});
const renderedItems = computed<INodeCreateElement[]>(() => {
if (props.firstLevelItems.length > 0 && activeSubcategory.value === null)
return props.firstLevelItems;
// If active subcategory is * then we show all items
if (activeSubcategory.value?.key === '*') return props.searchItems;
// Otherwise we show only items that match the subcategory
if (subcategorizedItems.value.length > 0) return subcategorizedItems.value;
// Finally if none of the above is true we show the categorized items
return filteredCategorizedItems.value;
});
const isSearchVisible = computed<boolean>(() => {
if (subcategorizedItems.value.length === 0) return true;
return subcategorizedItems.value.length > 9;
});
// Methods
function trimTriggerNodeName(nodeName: string) {
return nodeName.toLowerCase().replace('trigger', '');
}
function getFilteredNodes(items: INodeCreateElement[]) {
// In order to support the old search we need to remove the 'trigger' part
const trimmedFilter = searchFilter.value.toLowerCase().replace('trigger', '');
return (
sublimeSearch<INodeCreateElement>(trimmedFilter, items, [
{ key: 'properties.nodeType.displayName', weight: 2 },
{ key: 'properties.nodeType.codex.alias', weight: 1 },
]) || []
).map(({ item }) => item);
}
function getScrollTop() {
return scrollableContainer.value?.scrollTop || 0;
}
function setScrollTop(scrollTop: number) {
if (scrollableContainer.value) {
scrollableContainer.value.scrollTop = scrollTop;
}
}
function onNodeFilterChange(filter: string) {
nodeCreatorStore.setFilter(filter);
}
function nodeFilterKeyDown(e: KeyboardEvent) {
// We only want to propagate 'Escape' as it closes the node-creator and
// 'Tab' which toggles it
if (!['Escape', 'Tab'].includes(e.key)) e.stopPropagation();
// Prevent cursors position change
if (['ArrowUp', 'ArrowDown'].includes(e.key)) e.preventDefault();
if (activeSubcategory.value) {
const activeList =
searchFilter.value.length > 0 ? filteredNodeTypes.value : renderedItems.value;
const activeNodeType = activeList[state.activeSubcategoryIndex];
if (e.key === 'ArrowDown' && activeSubcategory.value) {
state.activeSubcategoryIndex++;
state.activeSubcategoryIndex = Math.min(state.activeSubcategoryIndex, activeList.length - 1);
} else if (e.key === 'ArrowUp' && activeSubcategory.value) {
state.activeSubcategoryIndex--;
state.activeSubcategoryIndex = Math.max(state.activeSubcategoryIndex, 0);
} else if (e.key === 'Enter') {
selected(activeNodeType);
} else if (
e.key === 'ArrowLeft' &&
activeNodeType?.type === 'category' &&
(activeNodeType.properties as ICategoryItemProps).expanded
) {
selected(activeNodeType);
} else if (e.key === 'ArrowLeft') {
onBackButton();
} else if (
e.key === 'ArrowRight' &&
activeNodeType?.type === 'category' &&
!(activeNodeType.properties as ICategoryItemProps).expanded
) {
selected(activeNodeType);
} else if (e.key === 'ArrowRight' && ['node', 'action'].includes(activeNodeType?.type)) {
selected(activeNodeType);
}
return;
}
const activeList = searchFilter.value.length > 0 ? filteredNodeTypes.value : renderedItems.value;
const activeNodeType = activeList[state.activeIndex];
if (e.key === 'ArrowDown') {
state.activeIndex++;
// Make sure that we stop at the last nodeType
state.activeIndex = Math.min(state.activeIndex, activeList.length - 1);
} else if (e.key === 'ArrowUp') {
state.activeIndex--;
// Make sure that we do not get before the first nodeType
state.activeIndex = Math.max(state.activeIndex, 0);
} else if (e.key === 'Enter' && activeNodeType) {
selected(activeNodeType);
} else if (e.key === 'ArrowRight' && activeNodeType?.type === 'subcategory') {
selected(activeNodeType);
} else if (e.key === 'ArrowRight' && activeNodeType?.type === 'view') {
selected(activeNodeType);
} else if (
e.key === 'ArrowRight' &&
activeNodeType?.type === 'category' &&
!(activeNodeType.properties as ICategoryItemProps).expanded
) {
selected(activeNodeType);
} else if (
e.key === 'ArrowLeft' &&
activeNodeType?.type === 'category' &&
(activeNodeType.properties as ICategoryItemProps).expanded
) {
selected(activeNodeType);
} else if (e.key === 'ArrowLeft' && isViewNavigated.value) {
onBackButton();
} else if (e.key === 'ArrowRight' && ['node', 'action'].includes(activeNodeType?.type)) {
selected(activeNodeType);
}
}
function selected(element: INodeCreateElement) {
const typeHandler = {
category: () => onCategorySelected(element),
subcategory: () => onSubcategorySelected(element),
node: () => onNodeSelected(element as NodeCreateElement),
action: () => onActionSelected(element),
view: () => onViewSelected(element),
};
typeHandler[element.type]();
}
function onViewSelected(view: Record<string, any>) {
state.transitionDirection = 'in';
state.activeIndex = 0;
nodeCreatorStore.setSelectedView(view.key);
nodeCreatorStore.setFilter('');
}
function onNodeSelected(element: NodeCreateElement) {
const hasActions = (element.properties.nodeType?.actions?.length || 0) > 0;
if (props.withActionsGetter && props.withActionsGetter(element) === true && hasActions) {
emit('actionsOpen', element.properties.nodeType);
return;
}
emit('nodeTypeSelected', [element.key]);
}
function onCategorySelected(element: CategoryCreateElement) {
const categoryKey = element.properties.category;
if (state.activeCategories.includes(categoryKey)) {
state.activeCategories = state.activeCategories.filter(
(active: string) => active !== categoryKey,
);
} else {
state.activeCategories = [...state.activeCategories, categoryKey];
instance?.proxy.$telemetry.trackNodesPanel('nodeCreateList.onCategoryExpanded', {
category_name: categoryKey,
workflow_id: workflowId,
});
}
}
function onActionSelected(element: INodeCreateElement) {
emit('actionSelected', element);
}
function onSubcategorySelected(selected: INodeCreateElement, track = true) {
state.transitionDirection = 'in';
// Store the current subcategory UI details in the state
// so we could revert it when the user closes the subcategory
state.activeSubcategoryHistory.push({
subcategory: selected,
activeIndex: state.activeSubcategoryIndex,
scrollPosition: getScrollTop(),
filter: nodeCreatorStore.itemsFilter,
});
nodeCreatorStore.setFilter('');
emit('onSubcategorySelected', selected);
state.activeSubcategoryIndex = 0;
if (track) {
instance?.proxy.$telemetry.trackNodesPanel('nodeCreateList.onSubcategorySelected', {
selected,
workflow_id: workflowId,
});
}
}
async function onBackButton() {
state.transitionDirection = 'out';
// Switching views
if (isRootView.value && isViewNavigated.value) {
nodeCreatorStore.closeCurrentView();
return;
}
const poppedSubCategory = state.activeSubcategoryHistory.pop();
onNodeFilterChange(poppedSubCategory?.filter || '');
await nextTick();
emit(
'subcategoryClose',
state.activeSubcategoryHistory.map((el) => el.subcategory),
);
await nextTick();
setScrollTop(poppedSubCategory?.scrollPosition || 0);
state.activeSubcategoryIndex = poppedSubCategory?.activeIndex || 0;
}
watch(
() => props.subcategoryOverride,
(subcategory) => {
if (subcategory) onSubcategorySelected(subcategory, false);
},
);
watch(
() => props.categorizedItems,
() => {
state.activeCategories = [...categoriesKeys.value, OTHER_RESULT_CATEGORY];
},
);
onUnmounted(() => {
nodeCreatorStore.setFilter('');
});
watch(filteredNodeTypes, (returnItems) => {
externalHooks.run('nodeCreateList.filteredNodeTypesComputed', {
nodeFilter: nodeCreatorStore.itemsFilter,
result: returnItems,
selectedType: nodeCreatorStore.selectedView,
});
});
watch(isSearchVisible, (isVisible) => {
if (isVisible === false) {
// Focus the root container when search is hidden to make sure
// keyboard navigation still works
nextTick(() => state.mainPanelContainer?.focus());
}
});
watch(
() => nodeCreatorStore.itemsFilter,
(newValue, oldValue) => {
// Reset the index whenver the filter-value changes
state.activeIndex = 0;
state.activeSubcategoryIndex = 0;
externalHooks.run('nodeCreateList.nodeFilterChanged', {
oldValue,
newValue,
selectedType: nodeCreatorStore.selectedView,
filteredNodes: filteredNodeTypes.value,
});
instance?.proxy.$telemetry.trackNodesPanel('nodeCreateList.nodeFilterChanged', {
oldValue,
newValue,
selectedType: nodeCreatorStore.selectedView,
filteredNodes: filteredNodeTypes.value,
workflow_id: workflowId,
});
},
);
const { activeSubcategoryIndex, activeIndex, mainPanelContainer } = toRefs(state);
</script>
<style lang="scss" module>
:global(.panel-slide-in-leave-active),
:global(.panel-slide-in-enter-active),
:global(.panel-slide-out-leave-active),
:global(.panel-slide-out-enter-active) {
transition: transform 300ms ease;
position: absolute;
left: 0;
right: 0;
}
:global(.panel-slide-out-enter),
:global(.panel-slide-in-leave-to) {
transform: translateX(0);
z-index: -1;
}
:global(.panel-slide-out-leave-to),
:global(.panel-slide-in-enter) {
transform: translateX(100%);
// Make sure the leaving panel stays on top
// for the slide-out panel effect
z-index: 1;
}
.nodeIcon {
--node-icon-size: 16px;
margin-right: var(--spacing-s);
}
.categorizedItems {
background: white;
height: 100%;
background-color: $node-creator-background-color;
&:before {
box-sizing: border-box;
content: '';
border-left: 1px solid $node-creator-border-color;
width: 1px;
position: absolute;
height: 100%;
}
}
.footer {
font-size: var(--font-size-2xs);
color: var(--color-text-base);
margin: 0 var(--spacing-xs) 0;
padding: var(--spacing-4xs) 0;
line-height: var(--font-line-height-regular);
border-top: 1px solid #dbdfe7;
z-index: 1;
margin-top: -1px;
}
.header {
font-size: var(--font-size-l);
font-weight: var(--font-weight-bold);
line-height: var(--font-line-height-compact);
display: flex;
align-items: center;
padding: var(--spacing-s) var(--spacing-s) var(--spacing-2xs);
&.headerWithBackground {
border-bottom: $node-creator-border-color solid 1px;
height: 50px;
background-color: $node-creator-subcategory-panel-header-bacground-color;
padding: var(--spacing-s) var(--spacing-s);
}
}
.description {
padding: 0 var(--spacing-s) var(--spacing-2xs) var(--spacing-s);
margin-top: -4px;
}
.descriptionOffset {
margin-left: calc(var(--spacing-xl) + var(--spacing-4xs));
}
.backButton {
background: transparent;
border: none;
cursor: pointer;
padding: 0 var(--spacing-xs) 0 0;
}
.subcategoryBackIcon {
color: $node-creator-arrow-color;
height: 16px;
padding: 0;
}
.scrollable {
height: calc(100% - 120px);
padding-top: 1px;
padding-bottom: var(--spacing-xl);
overflow-y: auto;
overflow-x: visible;
scrollbar-width: none; /* Firefox 64 */
&::-webkit-scrollbar {
display: none;
}
}
</style>

View file

@ -1,41 +0,0 @@
<template>
<div :class="$style.category">
<span :class="$style.name" v-text="item.name" />
<font-awesome-icon v-if="item.expanded" icon="chevron-down" :class="$style.arrow" />
<font-awesome-icon :class="$style.arrow" icon="chevron-up" v-else />
</div>
</template>
<script lang="ts" setup>
import type { ICategoryItemProps } from '@/Interface';
export interface Props {
item: ICategoryItemProps;
}
defineProps<Props>();
</script>
<style lang="scss" module>
.category {
font-size: 11px;
font-weight: var(--font-weight-bold);
letter-spacing: 1px;
line-height: 11px;
padding: 10px 0;
margin: 0 var(--spacing-xs);
border-bottom: 1px solid $node-creator-border-color;
display: flex;
text-transform: uppercase;
cursor: pointer;
}
.name {
flex-grow: 1;
}
.arrow {
font-size: var(--font-size-2xs);
width: 12px;
color: $node-creator-arrow-color;
}
</style>

View file

@ -1,210 +0,0 @@
<template>
<div
:class="$style.itemIterator"
name="accordion"
@before-enter="beforeEnter"
@enter="enter"
@before-leave="beforeLeave"
@leave="leave"
>
<div
v-for="(item, index) in renderedItems"
:key="`${item.key}-${index}`"
data-test-id="item-iterator-item"
:class="{
clickable: !disabled,
[$style[item.type]]: true,
[$style.active]: activeIndex === index && !disabled,
[$style.iteratorItem]: true,
}"
ref="iteratorItems"
@click="wrappedEmit('selected', item)"
>
<category-item v-if="item.type === 'category'" :item="item.properties" />
<subcategory-item v-else-if="item.type === 'subcategory'" :item="item.properties" />
<node-item
v-else-if="item.type === 'node'"
:nodeType="item.properties.nodeType"
:allow-actions="withActionsGetter && withActionsGetter(item)"
:allow-description="withDescriptionGetter && withDescriptionGetter(item)"
@dragstart="wrappedEmit('dragstart', item, $event)"
@dragend="wrappedEmit('dragend', item, $event)"
@nodeTypeSelected="$listeners.nodeTypeSelected"
@actionsOpen="$listeners.actionsOpen"
/>
<action-item
v-else-if="item.type === 'action'"
:nodeType="item.properties.nodeType"
:action="item.properties.nodeType"
@dragstart="wrappedEmit('dragstart', item, $event)"
@dragend="wrappedEmit('dragend', item, $event)"
/>
<view-item v-else-if="item.type === 'view'" :view="item.properties" />
</div>
<aside
v-for="item in elements.length"
v-show="renderedItems.length < item"
:key="item"
:class="$style.loadingItem"
>
<n8n-loading :loading="true" :rows="1" variant="p" />
</aside>
</div>
</template>
<script setup lang="ts">
import type { INodeCreateElement, NodeCreateElement } from '@/Interface';
import NodeItem from './NodeItem.vue';
import SubcategoryItem from './SubcategoryItem.vue';
import CategoryItem from './CategoryItem.vue';
import ActionItem from './ActionItem.vue';
import ViewItem from './ViewItem.vue';
import { reactive, toRefs, onMounted, watch, onUnmounted, ref } from 'vue';
export interface Props {
elements: INodeCreateElement[];
activeIndex?: number;
disabled?: boolean;
lazyRender?: boolean;
withActionsGetter?: (element: NodeCreateElement) => boolean;
withDescriptionGetter?: (element: NodeCreateElement) => boolean;
enableGlobalCategoriesCounter?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
elements: () => [],
});
const emit = defineEmits<{
(event: 'selected', element: INodeCreateElement, $e?: Event): void;
(event: 'dragstart', element: INodeCreateElement, $e: Event): void;
(event: 'dragend', element: INodeCreateElement, $e: Event): void;
}>();
const state = reactive({
renderedItems: [] as INodeCreateElement[],
renderAnimationRequest: 0,
});
const iteratorItems = ref<HTMLElement[]>([]);
function wrappedEmit(
event: 'selected' | 'dragstart' | 'dragend',
element: INodeCreateElement,
$e?: Event,
) {
if (props.disabled) return;
emit((event as 'selected') || 'dragstart' || 'dragend', element, $e);
}
// Lazy render large items lists to prevent the browser from freezing
// when loading many items.
function renderItems() {
if (props.elements.length <= 20 || props.lazyRender === false) {
state.renderedItems = props.elements;
return;
}
if (state.renderedItems.length < props.elements.length) {
state.renderedItems.push(
...props.elements.slice(state.renderedItems.length, state.renderedItems.length + 10),
);
state.renderAnimationRequest = window.requestAnimationFrame(renderItems);
}
}
function beforeEnter(el: HTMLElement) {
el.style.height = '0';
}
function enter(el: HTMLElement) {
el.style.height = `${el.scrollHeight}px`;
}
function beforeLeave(el: HTMLElement) {
el.style.height = `${el.scrollHeight}px`;
}
function leave(el: HTMLElement) {
el.style.height = '0';
}
onMounted(() => {
renderItems();
});
onUnmounted(() => {
window.cancelAnimationFrame(state.renderAnimationRequest);
state.renderedItems = [];
});
// Make sure the active item is always visible
// scroll if needed
watch(
() => props.activeIndex,
async () => {
if (props.activeIndex === undefined) return;
iteratorItems.value[props.activeIndex]?.scrollIntoView({ block: 'nearest' });
},
);
// Trigger elements re-render when they change
watch(
() => props.elements,
async () => {
window.cancelAnimationFrame(state.renderAnimationRequest);
state.renderedItems = [];
renderItems();
},
);
const { renderedItems } = toRefs(state);
</script>
<style lang="scss" module>
.loadingItem {
height: 48px;
margin: 0 var(--search-margin, var(--spacing-s));
}
.iteratorItem {
// Make sure border is fully visible
margin-left: 1px;
position: relative;
&::before {
content: '';
position: absolute;
left: -1px;
top: 0;
bottom: 0;
border-left: 2px solid transparent;
}
&:hover::before {
border-color: $node-creator-item-hover-border-color;
}
&.active::before {
border-color: $color-primary !important;
}
&.category.singleCategory {
display: none;
}
}
.itemIterator {
> *:last-child {
margin-bottom: var(--spacing-2xl);
}
}
.action {
&:last-of-type {
margin-bottom: var(--spacing-s);
}
}
.node + .category {
margin-top: var(--spacing-s);
}
</style>

View file

@ -1,13 +1,12 @@
<template>
<n8n-node-creator-node
:key="`${action.actionKey}_${action.displayName}`"
@click="onActionClick(action)"
@dragstart="onDragStart"
@dragend="onDragEnd"
draggable
:class="$style.action"
:title="action.displayName"
:isTrigger="isTriggerAction(action)"
data-keyboard-nav="true"
>
<template #dragContent>
<div :class="$style.draggableDataTransfer" ref="draggableDataTransfer" />
@ -23,22 +22,27 @@
<script setup lang="ts">
import { reactive, computed, toRefs, getCurrentInstance } from 'vue';
import type { INodeTypeDescription, INodeActionTypeDescription } from 'n8n-workflow';
import type { ActionTypeDescription, SimplifiedNodeType } from '@/Interface';
import { WEBHOOK_NODE_TYPE } from '@/constants';
import { getNewNodePosition, NODE_SIZE } from '@/utils/nodeViewUtils';
import type { IUpdateInformation } from '@/Interface';
import NodeIcon from '@/components/NodeIcon.vue';
import { useNodeCreatorStore } from '@/stores/nodeCreator';
import { useViewStacks } from '../composables/useViewStacks';
import { useActions } from '../composables/useActions';
export interface Props {
nodeType: INodeTypeDescription;
action: INodeActionTypeDescription;
nodeType: SimplifiedNodeType;
action: ActionTypeDescription;
}
const props = defineProps<Props>();
const instance = getCurrentInstance();
const telemetry = instance?.proxy.$telemetry;
const { getActionData, getNodeTypesWithManualTrigger, setAddedNodeActionParameters } =
useNodeCreatorStore();
const { getActionData, getNodeTypesWithManualTrigger, setAddedNodeActionParameters } = useActions();
const { activeViewStack } = useViewStacks();
const state = reactive({
dragging: false,
@ -49,11 +53,6 @@ const state = reactive({
storeWatcher: null as Function | null,
draggableDataTransfer: null as Element | null,
});
const emit = defineEmits<{
(event: 'actionSelected', action: IUpdateInformation): void;
(event: 'dragstart', $e: DragEvent): void;
(event: 'dragend', $e: DragEvent): void;
}>();
const draggableStyle = computed<{ top: string; left: string }>(() => ({
top: `${state.draggablePosition.y}px`,
@ -62,11 +61,8 @@ const draggableStyle = computed<{ top: string; left: string }>(() => ({
const actionData = computed(() => getActionData(props.action));
const isTriggerAction = (action: INodeActionTypeDescription) =>
action.name?.toLowerCase().includes('trigger');
function onActionClick(actionItem: INodeActionTypeDescription) {
emit('actionSelected', getActionData(actionItem));
}
const isTriggerAction = (action: ActionTypeDescription) =>
action.name?.toLowerCase().includes('trigger') || action.name === WEBHOOK_NODE_TYPE;
function onDragStart(event: DragEvent): void {
/**
@ -84,14 +80,18 @@ function onDragStart(event: DragEvent): void {
'nodeTypeName',
getNodeTypesWithManualTrigger(actionData.value?.key).join(','),
);
state.storeWatcher = setAddedNodeActionParameters(actionData.value, telemetry);
if (telemetry) {
state.storeWatcher = setAddedNodeActionParameters(
actionData.value,
telemetry,
activeViewStack.rootView,
);
}
document.body.addEventListener('dragend', onDragEnd);
}
state.dragging = true;
state.draggablePosition = { x, y };
emit('dragstart', event);
}
function onDragOver(event: DragEvent): void {
@ -109,8 +109,6 @@ function onDragEnd(event: DragEvent): void {
document.body.removeEventListener('dragend', onDragEnd);
document.body.removeEventListener('dragover', onDragOver);
emit('dragend', event);
state.dragging = false;
setTimeout(() => {
state.draggablePosition = { x: -100, y: -100 };
@ -121,25 +119,19 @@ const { draggableDataTransfer, dragging } = toRefs(state);
<style lang="scss" module>
.action {
margin-left: 15px;
margin-right: 12px;
--node-creator-name-size: var(--font-size-2xs);
--node-creator-name-weight: var(--font-weight-normal);
--trigger-icon-background-color: #{$trigger-icon-background-color};
--trigger-icon-border-color: #{$trigger-icon-border-color};
--node-icon-size: 20px;
--node-icon-margin-right: var(--spacing-xs);
margin-left: var(--spacing-s);
margin-right: var(--spacing-s);
padding: var(--spacing-2xs) 0;
}
.nodeIcon {
margin-right: var(--spacing-s);
}
.apiHint {
font-size: var(--font-size-2xs);
color: var(--color-text-base);
padding-top: var(--spacing-s);
line-height: var(--font-line-height-regular);
border-top: 1px solid #dbdfe7;
z-index: 1;
// Prevent double borders when the last category is collapsed
margin-top: -1px;
margin-right: var(--spacing-xs);
}
.draggable {

View file

@ -0,0 +1,81 @@
<script lang="ts" setup>
import { computed } from 'vue';
export interface Props {
expanded?: boolean;
active?: boolean;
count?: number;
name: string;
isTrigger?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
expanded: true,
});
const categoryName = computed(() => {
const itemsCount = props.count || 0;
return itemsCount > 0 ? `${props.name} (${itemsCount})` : props.name;
});
</script>
<template>
<div
:class="$style.categoryWrapper"
v-on="$listeners"
data-keyboard-nav="true"
data-test-id="node-creator-category-item"
>
<div :class="{ [$style.category]: true, [$style.active]: active }">
<span :class="$style.name">
<span v-text="categoryName" />
<font-awesome-icon icon="bolt" v-if="isTrigger" size="xs" :class="$style.triggerIcon" />
<slot />
</span>
<font-awesome-icon v-if="expanded" icon="chevron-down" :class="$style.arrow" />
<font-awesome-icon :class="$style.arrow" icon="chevron-up" v-else />
</div>
</div>
</template>
<style lang="scss" module>
.triggerIcon {
color: var(--color-primary);
margin-left: var(--spacing-3xs);
}
.category {
font-size: var(--font-size-s);
font-weight: var(--font-weight-bold);
line-height: var(--font-line-height-compact);
padding: var(--spacing-2xs) var(--spacing-s);
border-bottom: 1px solid $node-creator-border-color;
display: flex;
cursor: pointer;
position: relative;
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
border-left: 2px solid transparent;
}
&:hover::before {
border-color: $node-creator-item-hover-border-color;
}
&.active::before {
border-color: $color-primary;
}
}
.name {
flex-grow: 1;
color: var(--color-text-dark);
}
.arrow {
font-size: var(--font-size-2xs);
width: 12px;
color: $node-creator-arrow-color;
}
</style>

View file

@ -0,0 +1,31 @@
<template>
<div :class="$style.label">
<span :class="$style.name" v-text="item.key" />
</div>
</template>
<script lang="ts" setup>
import type { LabelItemProps } from '@/Interface';
export interface Props {
item: LabelItemProps;
}
defineProps<Props>();
</script>
<style lang="scss" module>
.label {
margin-left: var(--spacing-s);
margin-right: var(--spacing-s);
margin-bottom: var(--spacing-4xs);
letter-spacing: 1px;
padding-top: var(--spacing-s);
font-style: normal;
font-weight: 700;
font-size: 10px;
line-height: 12px;
text-transform: uppercase;
color: var(--color-text-base);
cursor: default;
}
</style>

View file

@ -4,12 +4,11 @@
:draggable="!showActionArrow"
@dragstart="onDragStart"
@dragend="onDragEnd"
@click.stop="onClick"
:class="$style.nodeItem"
:description="allowDescription ? description : ''"
:description="subcategory !== DEFAULT_SUBCATEGORY ? description : ''"
:title="displayName"
:isTrigger="!allowActions && isTriggerNode"
:show-action-arrow="showActionArrow"
:is-trigger="isTrigger"
>
<template #icon>
<node-icon :nodeType="nodeType" />
@ -39,62 +38,59 @@
</template>
<script setup lang="ts">
import { reactive, computed, toRefs, getCurrentInstance } from 'vue';
import type { INodeTypeDescription } from 'n8n-workflow';
import { computed, ref, getCurrentInstance } from 'vue';
import type { SimplifiedNodeType } from '@/Interface';
import { COMMUNITY_NODES_INSTALLATION_DOCS_URL, DEFAULT_SUBCATEGORY } from '@/constants';
import { getNewNodePosition, NODE_SIZE } from '@/utils/nodeViewUtils';
import { isCommunityPackageName } from '@/utils';
import { COMMUNITY_NODES_INSTALLATION_DOCS_URL } from '@/constants';
import { getNewNodePosition, NODE_SIZE } from '@/utils/nodeViewUtils';
import { useNodeCreatorStore } from '@/stores/nodeCreator';
import NodeIcon from '@/components/NodeIcon.vue';
import { useActions } from '../composables/useActions';
export interface Props {
nodeType: INodeTypeDescription;
nodeType: SimplifiedNodeType;
subcategory?: string;
active?: boolean;
allowActions?: boolean;
allowDescription?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
active: false,
allowActions: false,
allowDescription: false,
});
const emit = defineEmits<{
(event: 'dragstart', $e: DragEvent): void;
(event: 'dragend', $e: DragEvent): void;
(event: 'nodeTypeSelected', value: string[]): void;
(event: 'actionsOpen', value: INodeTypeDescription): void;
}>();
const { actions } = useNodeCreatorStore();
const { getNodeTypesWithManualTrigger } = useActions();
const instance = getCurrentInstance();
const state = reactive({
dragging: false,
draggablePosition: {
x: -100,
y: -100,
},
draggableDataTransfer: null as Element | null,
});
const dragging = ref(false);
const draggablePosition = ref({ x: -100, y: -100 });
const draggableDataTransfer = ref(null as Element | null);
const description = computed<string>(() => {
return instance?.proxy.$locale.headerText({
key: `headers.${shortNodeType.value}.description`,
fallback: props.nodeType.description,
}) as string;
});
const showActionArrow = computed(() => props.allowActions && hasActions.value);
const showActionArrow = computed(() => hasActions.value);
const hasActions = computed<boolean>(() => (props.nodeType.actions?.length || 0) > 0);
const hasActions = computed(() => {
return nodeActions.value.length > 1;
});
const nodeActions = computed(() => {
const nodeActions = actions[props.nodeType.name] || [];
return nodeActions;
});
const shortNodeType = computed<string>(
() => instance?.proxy.$locale.shortNodeType(props.nodeType.name) || '',
);
const draggableStyle = computed<{ top: string; left: string }>(() => ({
top: `${state.draggablePosition.y}px`,
left: `${state.draggablePosition.x}px`,
top: `${draggablePosition.value.y}px`,
left: `${draggablePosition.value.x}px`,
}));
const isCommunityNode = computed<boolean>(() => isCommunityPackageName(props.nodeType.name));
@ -105,21 +101,13 @@ const displayName = computed<any>(() => {
return instance?.proxy.$locale.headerText({
key: `headers.${shortNodeType}.displayName`,
fallback:
props.allowActions && props.nodeType.actions?.length
? displayName.replace('Trigger', '')
: displayName,
fallback: hasActions.value ? displayName.replace('Trigger', '') : displayName,
});
});
const isTriggerNode = computed<boolean>(() =>
props.nodeType.displayName.toLowerCase().includes('trigger'),
);
function onClick() {
if (hasActions.value && props.allowActions) emit('actionsOpen', props.nodeType);
else emit('nodeTypeSelected', [props.nodeType.name]);
}
const isTrigger = computed<boolean>(() => {
return props.nodeType.group.includes('trigger') && !hasActions.value;
});
function onDragStart(event: DragEvent): void {
/**
* Workaround for firefox, that doesn't attach the pageX and pageY coordinates to "ondrag" event.
@ -133,36 +121,33 @@ function onDragStart(event: DragEvent): void {
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'copy';
event.dataTransfer.dropEffect = 'copy';
event.dataTransfer.setDragImage(state.draggableDataTransfer as Element, 0, 0);
event.dataTransfer.setDragImage(draggableDataTransfer.value as Element, 0, 0);
event.dataTransfer.setData(
'nodeTypeName',
useNodeCreatorStore().getNodeTypesWithManualTrigger(props.nodeType.name).join(','),
getNodeTypesWithManualTrigger(props.nodeType.name).join(','),
);
}
state.dragging = true;
state.draggablePosition = { x, y };
emit('dragstart', event);
dragging.value = true;
draggablePosition.value = { x, y };
}
function onDragOver(event: DragEvent): void {
if (!state.dragging || (event.pageX === 0 && event.pageY === 0)) {
if (!dragging.value || (event.pageX === 0 && event.pageY === 0)) {
return;
}
const [x, y] = getNewNodePosition([], [event.pageX - NODE_SIZE / 2, event.pageY - NODE_SIZE / 2]);
state.draggablePosition = { x, y };
draggablePosition.value = { x, y };
}
function onDragEnd(event: DragEvent): void {
document.body.removeEventListener('dragover', onDragOver);
emit('dragend', event);
state.dragging = false;
dragging.value = false;
setTimeout(() => {
state.draggablePosition = { x: -100, y: -100 };
draggablePosition.value = { x: -100, y: -100 };
}, 300);
}
@ -171,11 +156,6 @@ function onCommunityNodeTooltipClick(event: MouseEvent) {
instance?.proxy.$telemetry.track('user clicked cnr docs link', { source: 'nodes panel node' });
}
}
defineExpose({
onClick,
});
const { dragging, draggableDataTransfer } = toRefs(state);
</script>
<style lang="scss" module>
.nodeItem {

View file

@ -13,15 +13,15 @@
</template>
<script setup lang="ts">
import type { ISubcategoryItemProps } from '@/Interface';
import type { SubcategoryItemProps } from '@/Interface';
import { camelCase } from 'lodash-es';
import { computed } from 'vue';
export interface Props {
item: ISubcategoryItemProps;
item: SubcategoryItemProps;
}
const props = defineProps<Props>();
const subcategoryName = computed(() => camelCase(props.item.subcategory));
const subcategoryName = computed(() => camelCase(props.item.subcategory || props.item.title));
</script>
<style lang="scss" module>
@ -30,9 +30,4 @@ const subcategoryName = computed(() => camelCase(props.item.subcategory));
margin-left: 15px;
margin-right: 12px;
}
.withTopBorder {
border-top: 1px solid var(--color-foreground-base);
margin-top: var(--spacing-m);
padding-top: var(--spacing-l);
}
</style>

View file

@ -1,21 +1,13 @@
<template>
<n8n-node-creator-node
:class="{
[$style.view]: true,
[$style.withTopBorder]: view.withTopBorder,
}"
:class="$style.view"
:title="view.title"
:isTrigger="false"
:description="view.description"
:showActionArrow="true"
>
<template #icon>
<n8n-node-icon
type="icon"
:name="view.icon"
:circle="false"
:showTooltip="false"
></n8n-node-icon>
<n8n-node-icon type="icon" :name="view.icon" :circle="false" :showTooltip="false" />
</template>
</n8n-node-creator-node>
</template>
@ -33,13 +25,7 @@ defineProps<Props>();
<style lang="scss" module>
.view {
--action-arrow-color: var(--color-text-light);
margin-left: 15px;
margin-right: 12px;
padding: 11px 4px 11px 0;
}
.withTopBorder {
border-top: 1px solid var(--color-foreground-base);
margin-top: var(--spacing-xs);
padding-top: var(--spacing-l);
margin-left: var(--spacing-s);
margin-right: var(--spacing-xs);
}
</style>

View file

@ -1,405 +0,0 @@
<template>
<div :class="{ [$style.mainPanel]: true, [$style.isRoot]: isRoot }">
<CategorizedItems
:subcategoryOverride="nodeAppSubcategory"
:alwaysShowSearch="isActionsActive"
:hideOtherCategoryItems="isActionsActive"
:categorizedItems="computedCategorizedItems"
:searchItems="searchItems"
:withActionsGetter="shouldShowNodeActions"
:withDescriptionGetter="shouldShowNodeDescription"
:firstLevelItems="firstLevelItems"
:showSubcategoryIcon="isActionsActive"
:allItems="transformCreateElements(mergedAppNodes)"
:searchPlaceholder="searchPlaceholder"
@subcategoryClose="onSubcategoryClose"
@onSubcategorySelected="onSubcategorySelected"
@nodeTypeSelected="onNodeTypeSelected"
@actionsOpen="setActiveActionsNodeType"
@actionSelected="onActionSelected"
>
<template #noResults>
<no-results
data-test-id="categorized-no-results"
:showRequest="!isActionsActive"
:show-icon="!isActionsActive"
>
<template #title v-if="!isActionsActive">
<p v-text="$locale.baseText('nodeCreator.noResults.weDidntMakeThatYet')" />
</template>
<template v-if="isActionsActive" #action>
<p
v-if="containsAPIAction"
v-html="getCustomAPICallHintLocale('apiCallNoResult')"
class="clickable"
@click.stop="addHttpNode(true)"
/>
<p v-else v-text="$locale.baseText('nodeCreator.noResults.noMatchingActions')" />
</template>
<template v-else #action>
{{ $locale.baseText('nodeCreator.noResults.dontWorryYouCanProbablyDoItWithThe') }}
<n8n-link v-if="[REGULAR_NODE_FILTER].includes(selectedView)" @click="addHttpNode">
{{ $locale.baseText('nodeCreator.noResults.httpRequest') }}
</n8n-link>
<n8n-link v-if="[TRIGGER_NODE_FILTER].includes(selectedView)" @click="addWebHookNode()">
{{ $locale.baseText('nodeCreator.noResults.webhook') }}
</n8n-link>
{{ $locale.baseText('nodeCreator.noResults.node') }}
</template>
</no-results>
</template>
<template #header>
<p
v-if="isRoot && activeView && activeView.title"
v-text="activeView.title"
:class="$style.title"
/>
</template>
<template #description>
<p
v-if="isRoot && activeView && activeView.description"
v-text="activeView.description"
:class="$style.description"
/>
</template>
<template #footer v-if="activeNodeActions && containsAPIAction">
<span
v-html="getCustomAPICallHintLocale('apiCall')"
class="clickable"
@click.stop="addHttpNode(true)"
/>
</template>
</CategorizedItems>
</div>
</template>
<script setup lang="ts">
import { reactive, toRefs, getCurrentInstance, computed, onUnmounted } from 'vue';
import type { INodeTypeDescription, INodeActionTypeDescription } from 'n8n-workflow';
import type {
INodeCreateElement,
NodeCreateElement,
IActionItemProps,
SubcategoryCreateElement,
IUpdateInformation,
} from '@/Interface';
import {
CORE_NODES_CATEGORY,
WEBHOOK_NODE_TYPE,
EMAIL_IMAP_NODE_TYPE,
CUSTOM_API_CALL_NAME,
HTTP_REQUEST_NODE_TYPE,
STICKY_NODE_TYPE,
REGULAR_NODE_FILTER,
TRIGGER_NODE_FILTER,
} from '@/constants';
import CategorizedItems from './CategorizedItems.vue';
import { useNodeCreatorStore } from '@/stores/nodeCreator';
import { getCategoriesWithNodes, getCategorizedList } from '@/utils';
import { useNodeTypesStore } from '@/stores/nodeTypes';
import type { BaseTextKey } from '@/plugins/i18n';
import NoResults from './NoResults.vue';
import { useRootStore } from '@/stores/n8nRootStore';
import useMainPanelView from './useMainPanelView';
import { useExternalHooks } from '@/composables';
const instance = getCurrentInstance();
const emit = defineEmits({
nodeTypeSelected: (nodeTypes: string[]) => true,
});
const state = reactive({
isRoot: true,
selectedSubcategory: '',
activeNodeActions: null as INodeTypeDescription | null,
});
const { baseUrl } = useRootStore();
const externalHooks = useExternalHooks();
const {
mergedAppNodes,
getActionData,
getNodeTypesWithManualTrigger,
setAddedNodeActionParameters,
} = useNodeCreatorStore();
const { activeView } = useMainPanelView();
const telemetry = instance?.proxy.$telemetry;
const { isTriggerNode } = useNodeTypesStore();
const containsAPIAction = computed(
() =>
state.activeNodeActions?.properties.some((p) =>
p.options?.find((o) => o.name === CUSTOM_API_CALL_NAME),
) === true,
);
const selectedView = computed(() => useNodeCreatorStore().selectedView);
const computedCategorizedItems = computed(() => {
if (isActionsActive.value) {
return sortActions(getCategorizedList(computedCategoriesWithNodes.value, true));
}
return getCategorizedList(computedCategoriesWithNodes.value, true);
});
const nodeAppSubcategory = computed<SubcategoryCreateElement | undefined>(() => {
if (!state.activeNodeActions) return undefined;
const icon = state.activeNodeActions.iconUrl
? `${baseUrl}${state.activeNodeActions.iconUrl}`
: state.activeNodeActions.icon?.split(':')[1];
return {
type: 'subcategory',
key: state.activeNodeActions.name,
properties: {
subcategory: state.activeNodeActions.displayName,
description: '',
iconType: state.activeNodeActions.iconUrl ? 'file' : 'icon',
icon,
color: state.activeNodeActions.defaults.color,
},
};
});
const searchPlaceholder = computed(() => {
const nodeNameTitle = state.activeNodeActions?.displayName?.trim() as string;
const actionsSearchPlaceholder = instance?.proxy.$locale.baseText(
'nodeCreator.actionsCategory.searchActions',
{ interpolate: { nodeNameTitle } },
);
return isActionsActive.value ? actionsSearchPlaceholder : undefined;
});
const filteredMergedAppNodes = computed(() => {
const WHITELISTED_APP_CORE_NODES = [EMAIL_IMAP_NODE_TYPE, WEBHOOK_NODE_TYPE];
if (isAppEventSubcategory.value)
return mergedAppNodes.filter((node) => {
const isTrigger = isTriggerNode(node.name);
const isRegularNode = !isTrigger;
const isStickyNode = node.name === STICKY_NODE_TYPE;
const isCoreNode =
node.codex?.categories?.includes(CORE_NODES_CATEGORY) &&
!WHITELISTED_APP_CORE_NODES.includes(node.name);
const hasActions = (node.actions || []).length > 0;
// Never show core nodes and sticky node in the Apps subcategory
if (isCoreNode || isStickyNode) return false;
// Only show nodes without action within their view
if (!hasActions) {
return isRegularNode
? selectedView.value === REGULAR_NODE_FILTER
: selectedView.value === TRIGGER_NODE_FILTER;
}
return true;
});
return mergedAppNodes;
});
const computedCategoriesWithNodes = computed(() => {
if (!state.activeNodeActions) return getCategoriesWithNodes(filteredMergedAppNodes.value);
return getCategoriesWithNodes(selectedNodeActions.value, state.activeNodeActions.displayName);
});
const selectedNodeActions = computed<INodeActionTypeDescription[]>(
() => state.activeNodeActions?.actions ?? [],
);
const isAppEventSubcategory = computed(() => state.selectedSubcategory === '*');
const isActionsActive = computed(() => state.activeNodeActions !== null);
const firstLevelItems = computed(() => (isRoot.value ? activeView.value.items : []));
const searchItems = computed<INodeCreateElement[]>(() => {
return state.activeNodeActions
? transformCreateElements(selectedNodeActions.value, 'action')
: transformCreateElements(filteredMergedAppNodes.value);
});
// If the user is in the root view, we want to show trigger nodes first
// otherwise we want to show them last
function sortActions(nodeCreateElements: INodeCreateElement[]): INodeCreateElement[] {
const elements = {
trigger: [] as INodeCreateElement[],
regular: [] as INodeCreateElement[],
};
nodeCreateElements.forEach((el) => {
const isTriggersCategory = el.type === 'category' && el.key === 'Triggers';
const isTriggerAction = el.type === 'action' && el.category === 'Triggers';
elements[isTriggersCategory || isTriggerAction ? 'trigger' : 'regular'].push(el);
});
if (selectedView.value === TRIGGER_NODE_FILTER) {
return [...elements.trigger, ...elements.regular];
}
return [...elements.regular, ...elements.trigger];
}
function transformCreateElements(
createElements: Array<INodeTypeDescription | INodeActionTypeDescription>,
type: 'node' | 'action' = 'node',
): INodeCreateElement[] {
const sorted = [...createElements];
sorted.sort((a, b) => {
const textA = a.displayName.toLowerCase();
const textB = b.displayName.toLowerCase();
return textA < textB ? -1 : textA > textB ? 1 : 0;
});
return sorted.map((nodeType) => {
const hasTriggerActions = nodeType.actions?.find((action) => action.name.includes('trigger'));
const hasRgeularActions = nodeType.actions?.find((action) => !action.name.includes('trigger'));
return {
type,
category: nodeType.codex?.categories,
key: nodeType.name,
properties: {
nodeType,
subcategory: state.activeNodeActions?.displayName ?? '',
},
includedByTrigger: hasTriggerActions || nodeType.group.includes('trigger'),
includedByRegular: hasRgeularActions || !nodeType.group.includes('trigger'),
} as INodeCreateElement;
});
}
function onNodeTypeSelected(nodeTypes: string[]) {
emit(
'nodeTypeSelected',
nodeTypes.length === 1 ? getNodeTypesWithManualTrigger(nodeTypes[0]) : nodeTypes,
);
}
function getCustomAPICallHintLocale(key: string) {
if (!state.activeNodeActions) return '';
const nodeNameTitle = state.activeNodeActions.displayName;
return instance?.proxy.$locale.baseText(`nodeCreator.actionsList.${key}` as BaseTextKey, {
interpolate: { nodeNameTitle },
});
}
function setActiveActionsNodeType(nodeType: INodeTypeDescription | null) {
state.activeNodeActions = nodeType;
if (nodeType) trackActionsView();
}
function onActionSelected(actionCreateElement: INodeCreateElement) {
const action = (actionCreateElement.properties as IActionItemProps).nodeType;
const actionUpdateData = getActionData(action);
emit('nodeTypeSelected', getNodeTypesWithManualTrigger(actionUpdateData.key));
setAddedNodeActionParameters(actionUpdateData, telemetry);
}
function addWebHookNode() {
emit('nodeTypeSelected', [WEBHOOK_NODE_TYPE]);
}
function addHttpNode(isAction: boolean) {
const updateData = {
name: '',
key: HTTP_REQUEST_NODE_TYPE,
value: {
authentication: 'predefinedCredentialType',
},
} as IUpdateInformation;
emit('nodeTypeSelected', [HTTP_REQUEST_NODE_TYPE]);
if (isAction) {
setAddedNodeActionParameters(updateData, telemetry, false);
const app_identifier = state.activeNodeActions?.name;
externalHooks.run('nodeCreateList.onActionsCustmAPIClicked', { app_identifier });
telemetry?.trackNodesPanel('nodeCreateList.onActionsCustmAPIClicked', { app_identifier });
}
}
function onSubcategorySelected(subcategory: INodeCreateElement) {
state.isRoot = false;
state.selectedSubcategory = subcategory.key;
}
function onSubcategoryClose(activeSubcategories: INodeCreateElement[]) {
if (isActionsActive.value === true) setActiveActionsNodeType(null);
state.isRoot = activeSubcategories.length === 0;
state.selectedSubcategory = activeSubcategories[activeSubcategories.length - 1]?.key ?? '';
}
function shouldShowNodeDescription(node: NodeCreateElement) {
return (node.category || []).includes(CORE_NODES_CATEGORY);
}
function shouldShowNodeActions(node: INodeCreateElement) {
if (state.isRoot && useNodeCreatorStore().itemsFilter === '') return false;
return true;
}
function trackActionsView() {
const trigger_action_count = selectedNodeActions.value.filter((action) =>
action.name.toLowerCase().includes('trigger'),
).length;
const trackingPayload = {
app_identifier: state.activeNodeActions?.name,
actions: selectedNodeActions.value.map((action) => action.displayName),
regular_action_count: selectedNodeActions.value.length - trigger_action_count,
trigger_action_count,
};
externalHooks.run('nodeCreateList.onViewActions', trackingPayload);
telemetry?.trackNodesPanel('nodeCreateList.onViewActions', trackingPayload);
}
onUnmounted(() => {
useNodeCreatorStore().resetRootViewHistory();
});
const { isRoot, activeNodeActions } = toRefs(state);
</script>
<style lang="scss" module>
.mainPanel {
--node-icon-color: var(--color-text-base);
height: 100%;
display: flex;
flex-direction: column;
// Remove node item border on the root level
&.isRoot {
--node-item-border: none;
}
}
.itemCreator {
height: calc(100% - 120px);
padding-top: 1px;
overflow-y: auto;
overflow-x: visible;
&::-webkit-scrollbar {
display: none;
}
}
.title {
font-size: var(--font-size-l);
line-height: var(--font-line-height-xloose);
font-weight: var(--font-weight-bold);
color: var(--color-text-dark);
}
.description {
font-size: var(--font-size-s);
line-height: var(--font-line-height-loose);
color: var(--color-text-base);
}
</style>

View file

@ -0,0 +1,366 @@
<script setup lang="ts">
import { computed, getCurrentInstance, onMounted, defineComponent } from 'vue';
import type { VNode, PropType } from 'vue';
import type {
INodeCreateElement,
ActionTypeDescription,
NodeFilterType,
IUpdateInformation,
ActionCreateElement,
} from '@/Interface';
import {
HTTP_REQUEST_NODE_TYPE,
REGULAR_NODE_CREATOR_VIEW,
TRIGGER_NODE_CREATOR_VIEW,
CUSTOM_API_CALL_KEY,
AUTO_INSERT_ACTION_EXPERIMENT,
} from '@/constants';
import { usePostHog } from '@/stores/posthog';
import { useUsersStore } from '@/stores/users';
import { useWebhooksStore } from '@/stores/webhooks';
import { runExternalHook } from '@/utils';
import { useActions } from '../composables/useActions';
import { useKeyboardNavigation } from '../composables/useKeyboardNavigation';
import { useViewStacks } from '../composables/useViewStacks';
import ItemsRenderer from '../Renderers/ItemsRenderer.vue';
import CategorizedItemsRenderer from '../Renderers/CategorizedItemsRenderer.vue';
const emit = defineEmits({
nodeTypeSelected: (nodeTypes: string[]) => true,
});
const instance = getCurrentInstance();
const telemetry = instance?.proxy.$telemetry;
const { userActivated } = useUsersStore();
const { popViewStack, updateCurrentViewStack } = useViewStacks();
const { registerKeyHook } = useKeyboardNavigation();
const {
getNodeTypesWithManualTrigger,
setAddedNodeActionParameters,
getActionData,
getPlaceholderTriggerActions,
parseCategoryActions,
actionsCategoryLocales,
} = useActions();
// We only inject labels if search is empty
const parsedTriggerActions = computed(() =>
parseActions(actions.value, actionsCategoryLocales.value.triggers, false),
);
const parsedActionActions = computed(() =>
parseActions(actions.value, actionsCategoryLocales.value.actions, !search.value),
);
const parsedTriggerActionsBaseline = computed(() =>
parseActions(
useViewStacks().activeViewStack.baselineItems || [],
actionsCategoryLocales.value.triggers,
false,
),
);
const parsedActionActionsBaseline = computed(() =>
parseActions(
useViewStacks().activeViewStack.baselineItems || [],
actionsCategoryLocales.value.actions,
!search.value,
),
);
// Because the placeholder items are inserted into the slots, we need to
// add the placeholder count to the category name manually
const triggerCategoryName = computed(() =>
parsedTriggerActions.value.length || search.value
? actionsCategoryLocales.value.triggers
: `${actionsCategoryLocales.value.triggers} (${placeholderTriggerActions.length})`,
);
const actions = computed(() => {
return (useViewStacks().activeViewStack.items || []).filter(
(p) => (p as ActionCreateElement).properties.actionKey !== CUSTOM_API_CALL_KEY,
);
});
const search = computed(() => useViewStacks().activeViewStack.search);
const subcategory = computed(() => useViewStacks().activeViewStack.subcategory);
const rootView = computed(() => useViewStacks().activeViewStack.rootView);
const placeholderTriggerActions = getPlaceholderTriggerActions(subcategory.value || '');
const hasNoTriggerActions = computed(
() =>
parseCategoryActions(
useViewStacks().activeViewStack.baselineItems || [],
actionsCategoryLocales.value.triggers,
!search.value,
).length === 0,
);
const containsAPIAction = computed(() => {
const actions = useViewStacks().activeViewStack.baselineItems || [];
const result = actions.some((p) => {
return ((p as ActionCreateElement).properties.actionKey ?? '') === CUSTOM_API_CALL_KEY;
});
return result === true;
});
const isTriggerRootView = computed(() => rootView.value === TRIGGER_NODE_CREATOR_VIEW);
registerKeyHook('ActionsKeyRight', {
keyboardKeys: ['ArrowRight', 'Enter'],
condition: (type) => type === 'action',
handler: onKeySelect,
});
registerKeyHook('ActionsKeyLeft', {
keyboardKeys: ['ArrowLeft'],
condition: (type) => type === 'action',
handler: arrowLeft,
});
function parseActions(base: INodeCreateElement[], locale: string, withLabels = false) {
return parseCategoryActions(base, locale, withLabels);
}
function arrowLeft() {
popViewStack();
}
function onKeySelect(activeItemId: string) {
const mergedActions = [...actions.value, ...placeholderTriggerActions];
const activeAction = mergedActions.find((a) => a.uuid === activeItemId);
if (activeAction) onSelected(activeAction);
}
function onSelected(actionCreateElement: INodeCreateElement) {
const actionData = getActionData(actionCreateElement.properties as ActionTypeDescription);
const isPlaceholderTriggerAction = placeholderTriggerActions.some(
(p) => p.key === actionCreateElement.key,
);
const includeNodeWithPlaceholderTrigger = usePostHog().isVariantEnabled(
AUTO_INSERT_ACTION_EXPERIMENT.name,
AUTO_INSERT_ACTION_EXPERIMENT.variant,
);
if (includeNodeWithPlaceholderTrigger && isPlaceholderTriggerAction && isTriggerRootView) {
const actionNode = actions.value[0].key;
emit('nodeTypeSelected', [actionData.key as string, actionNode]);
} else {
emit('nodeTypeSelected', getNodeTypesWithManualTrigger(actionData.key));
}
if (telemetry) setAddedNodeActionParameters(actionData, telemetry, rootView.value);
}
function trackActionsView() {
const activeViewStack = useViewStacks().activeViewStack;
const trigger_action_count = (activeViewStack.baselineItems || [])?.filter((action) =>
action.key.toLowerCase().includes('trigger'),
).length;
const appIdentifier = [...actions.value, ...placeholderTriggerActions][0].key;
const trackingPayload = {
app_identifier: appIdentifier,
actions: (activeViewStack.baselineItems || [])?.map(
(action) => (action as ActionCreateElement).properties.displayName,
),
regular_action_count: (activeViewStack.baselineItems || [])?.length - trigger_action_count,
trigger_action_count,
};
runExternalHook('nodeCreateList.onViewActions', useWebhooksStore(), trackingPayload);
telemetry?.trackNodesPanel('nodeCreateList.onViewActions', trackingPayload);
}
function resetSearch() {
updateCurrentViewStack({ search: '' });
}
function addHttpNode() {
const updateData = {
name: '',
key: HTTP_REQUEST_NODE_TYPE,
value: {
authentication: 'predefinedCredentialType',
},
} as IUpdateInformation;
emit('nodeTypeSelected', [HTTP_REQUEST_NODE_TYPE]);
if (telemetry) setAddedNodeActionParameters(updateData);
const app_identifier = actions.value[0].key;
runExternalHook('nodeCreateList.onActionsCustmAPIClicked', useWebhooksStore(), {
app_identifier,
});
telemetry?.trackNodesPanel('nodeCreateList.onActionsCustmAPIClicked', { app_identifier });
}
// Anonymous component to handle triggers and actions rendering order
const OrderSwitcher = defineComponent({
props: {
rootView: {
type: String as PropType<NodeFilterType>,
},
},
render(h): VNode {
const triggers = this.$slots?.triggers?.[0];
const actions = this.$slots?.actions?.[0];
return h(
'div',
{},
this.rootView === REGULAR_NODE_CREATOR_VIEW ? [actions, triggers] : [triggers, actions],
);
},
});
onMounted(() => {
trackActionsView();
});
</script>
<template>
<div :class="$style.container">
<OrderSwitcher :rootView="rootView">
<template #triggers v-if="isTriggerRootView || parsedTriggerActionsBaseline.length !== 0">
<!-- Triggers Category -->
<CategorizedItemsRenderer
:elements="parsedTriggerActions"
:category="triggerCategoryName"
:mouseOverTooltip="$locale.baseText('nodeCreator.actionsTooltip.triggersStartWorkflow')"
isTriggerCategory
:expanded="isTriggerRootView || parsedActionActions.length === 0"
@selected="onSelected"
>
<!-- Empty state -->
<template #empty>
<template v-if="hasNoTriggerActions">
<n8n-callout
theme="info"
iconless
slim
data-test-id="actions-panel-no-triggers-callout"
>
<span
v-html="
$locale.baseText('nodeCreator.actionsCallout.noTriggerItems', {
interpolate: { nodeName: subcategory },
})
"
/>
</n8n-callout>
<ItemsRenderer @selected="onSelected" :elements="placeholderTriggerActions" />
</template>
<template v-else>
<p
:class="$style.resetSearch"
v-html="$locale.baseText('nodeCreator.actionsCategory.noMatchingTriggers')"
@click="resetSearch"
/>
</template>
</template>
</CategorizedItemsRenderer>
</template>
<template #actions v-if="!isTriggerRootView || parsedActionActionsBaseline.length !== 0">
<!-- Actions Category -->
<CategorizedItemsRenderer
:elements="parsedActionActions"
:category="actionsCategoryLocales.actions"
:mouseOverTooltip="$locale.baseText('nodeCreator.actionsTooltip.actionsPerformStep')"
:expanded="!isTriggerRootView || parsedTriggerActions.length === 0"
@selected="onSelected"
>
<template>
<n8n-callout
theme="info"
iconless
v-if="!userActivated && isTriggerRootView"
slim
data-test-id="actions-panel-activation-callout"
>
<span v-html="$locale.baseText('nodeCreator.actionsCallout.triggersStartWorkflow')" />
</n8n-callout>
</template>
<!-- Empty state -->
<template #empty>
<n8n-info-tip theme="info" type="note" v-if="!search" :class="$style.actionsEmpty">
<span
v-html="
$locale.baseText('nodeCreator.actionsCallout.noActionItems', {
interpolate: { nodeName: subcategory },
})
"
/>
</n8n-info-tip>
<template v-else>
<p
:class="$style.resetSearch"
v-html="$locale.baseText('nodeCreator.actionsCategory.noMatchingActions')"
@click="resetSearch"
data-test-id="actions-panel-no-matching-actions"
/>
</template>
</template>
</CategorizedItemsRenderer>
</template>
</OrderSwitcher>
<div :class="$style.apiHint" v-if="containsAPIAction">
<span
@click.prevent="addHttpNode"
v-html="
$locale.baseText('nodeCreator.actionsList.apiCall', {
interpolate: { node: subcategory },
})
"
/>
</div>
</div>
</template>
<style lang="scss" module>
.container {
display: flex;
flex-direction: column;
padding-bottom: var(--spacing-3xl);
}
.resetSearch {
cursor: pointer;
line-height: var(--font-line-height-regular);
font-weight: var(--font-weight-regular);
font-size: var(--font-size-2xs);
padding: var(--spacing-2xs) var(--spacing-s) 0;
color: var(--color-text-base);
i {
font-weight: var(--font-weight-bold);
font-style: normal;
text-decoration: underline;
}
}
.actionsEmpty {
padding: var(--spacing-2xs) var(--spacing-xs) var(--spacing-s);
font-weight: var(--font-weight-regular);
strong {
font-weight: var(--font-weight-bold);
}
}
.apiHint {
padding: 0 var(--spacing-s) var(--spacing-xl);
font-size: var(--font-size-2xs);
color: var(--color-text-base);
line-height: var(--font-line-height-regular);
z-index: 1;
}
</style>

View file

@ -0,0 +1,213 @@
<script setup lang="ts">
import { camelCase } from 'lodash-es';
import { getCurrentInstance, computed } from 'vue';
import type { INodeCreateElement, NodeFilterType } from '@/Interface';
import { TRIGGER_NODE_CREATOR_VIEW, HTTP_REQUEST_NODE_TYPE, WEBHOOK_NODE_TYPE } from '@/constants';
import type { BaseTextKey } from '@/plugins/i18n';
import { useRootStore } from '@/stores/n8nRootStore';
import { useNodeCreatorStore } from '@/stores/nodeCreator';
import { TriggerView, RegularView } from '../viewsData';
import { transformNodeType } from '../utils';
import { useViewStacks } from '../composables/useViewStacks';
import { useActions } from '../composables/useActions';
import { useKeyboardNavigation } from '../composables/useKeyboardNavigation';
import ItemsRenderer from '../Renderers/ItemsRenderer.vue';
import CategorizedItemsRenderer from '../Renderers/CategorizedItemsRenderer.vue';
import NoResults from '../Panel/NoResults.vue';
export interface Props {
rootView: 'trigger' | 'action';
}
const emit = defineEmits({
nodeTypeSelected: (nodeTypes: string[]) => true,
});
const instance = getCurrentInstance();
const { mergedNodes, actions } = useNodeCreatorStore();
const { baseUrl } = useRootStore();
const { getNodeTypesWithManualTrigger } = useActions();
const { pushViewStack, popViewStack } = useViewStacks();
const { registerKeyHook } = useKeyboardNavigation();
const activeViewStack = computed(() => useViewStacks().activeViewStack);
const globalSearchItemsDiff = computed(() => useViewStacks().globalSearchItemsDiff);
function selectNodeType(nodeTypes: string[]) {
emit(
'nodeTypeSelected',
nodeTypes.length === 1 ? getNodeTypesWithManualTrigger(nodeTypes[0]) : nodeTypes,
);
}
function onSelected(item: INodeCreateElement) {
if (item.type === 'subcategory') {
const title = instance?.proxy.$locale.baseText(
`nodeCreator.subcategoryNames.${camelCase(item.properties.title)}` as BaseTextKey,
);
pushViewStack({
subcategory: item.key,
title,
mode: 'nodes',
rootView: activeViewStack.value.rootView,
forceIncludeNodes: item.properties.forceIncludeNodes,
baseFilter: baseSubcategoriesFilter,
itemsMapper: subcategoriesMapper,
});
instance?.proxy.$telemetry.trackNodesPanel('nodeCreateList.onSubcategorySelected', {
subcategory: item.key,
});
}
if (item.type === 'node') {
const nodeActions = actions?.[item.key] || [];
if (nodeActions.length <= 1) {
selectNodeType([item.key]);
return;
}
const icon = item.properties.iconUrl
? `${baseUrl}${item.properties.iconUrl}`
: item.properties.icon?.split(':')[1];
const transformedActions = nodeActions?.map((a) =>
transformNodeType(a, item.properties.displayName, 'action'),
);
pushViewStack({
subcategory: item.properties.displayName,
title: item.properties.displayName,
nodeIcon: {
color: item.properties.defaults?.color || '',
icon,
iconType: item.properties.iconUrl ? 'file' : 'icon',
},
rootView: activeViewStack.value.rootView,
hasSearch: true,
mode: 'actions',
items: transformedActions,
});
}
if (item.type === 'view') {
const view =
item.key === TRIGGER_NODE_CREATOR_VIEW
? TriggerView(instance?.proxy?.$locale)
: RegularView(instance?.proxy?.$locale);
pushViewStack({
title: view.title,
subtitle: view?.subtitle ?? '',
items: view.items as INodeCreateElement[],
hasSearch: true,
rootView: view.value as NodeFilterType,
mode: 'nodes',
// Root search should include all nodes
searchItems: mergedNodes,
});
}
}
function subcategoriesMapper(item: INodeCreateElement) {
if (item.type !== 'node') return item;
const hasTriggerGroup = item.properties.group.includes('trigger');
const nodeActions = actions?.[item.key] || [];
const hasActions = nodeActions.length > 0;
if (hasTriggerGroup && hasActions) {
if (item.properties?.codex) {
// Store the original name in the alias so we can search for it
item.properties.codex.alias = [
...(item.properties.codex?.alias || []),
item.properties.displayName,
];
}
item.properties.displayName = item.properties.displayName.replace(' Trigger', '');
}
return item;
}
function baseSubcategoriesFilter(item: INodeCreateElement) {
if (item.type !== 'node') return false;
const hasTriggerGroup = item.properties.group.includes('trigger');
const nodeActions = actions?.[item.key] || [];
const hasActions = nodeActions.length > 0;
const isTriggerRootView = activeViewStack.value.rootView === TRIGGER_NODE_CREATOR_VIEW;
if (isTriggerRootView) {
return hasActions || hasTriggerGroup;
}
return hasActions || !hasTriggerGroup;
}
function arrowLeft() {
popViewStack();
}
function onKeySelect(activeItemId: string) {
const mergedItems = [
...(activeViewStack.value.items || []),
...(globalSearchItemsDiff.value || []),
];
const item = mergedItems.find((i) => i.uuid === activeItemId);
if (!item) return;
onSelected(item as INodeCreateElement);
}
registerKeyHook('MainViewArrowRight', {
keyboardKeys: ['ArrowRight', 'Enter'],
condition: (type) => ['subcategory', 'node', 'view'].includes(type),
handler: onKeySelect,
});
registerKeyHook('MainViewArrowLeft', {
keyboardKeys: ['ArrowLeft'],
condition: (type) => ['subcategory', 'node', 'view'].includes(type),
handler: arrowLeft,
});
</script>
<template>
<span>
<!-- Main Node Items -->
<ItemsRenderer :elements="activeViewStack.items" @selected="onSelected" :class="$style.items">
<template
#empty
v-if="(activeViewStack.items || []).length === 0 && globalSearchItemsDiff.length === 0"
>
<NoResults
:rootView="activeViewStack.rootView"
showIcon
showRequest
@addWebhookNode="selectNodeType([WEBHOOK_NODE_TYPE])"
@addHttpNode="selectNodeType([HTTP_REQUEST_NODE_TYPE])"
/>
</template>
</ItemsRenderer>
<!-- Results in other categories -->
<CategorizedItemsRenderer
v-if="globalSearchItemsDiff.length > 0"
:elements="globalSearchItemsDiff"
:category="$locale.baseText('nodeCreator.categoryNames.otherCategories')"
@selected="onSelected"
>
</CategorizedItemsRenderer>
</span>
</template>
<style lang="scss" module>
.items {
margin-bottom: var(--spacing-s);
}
</style>

View file

@ -1,11 +1,10 @@
<template>
<div>
<aside :class="{ 'node-creator-scrim': true, active: nodeCreatorStore.showScrim }" />
<aside :class="{ [$style.nodeCreatorScrim]: true, [$style.active]: showScrim }" />
<slide-transition>
<div
v-if="active"
class="node-creator"
:class="$style.nodeCreator"
ref="nodeCreator"
v-click-outside="onClickOutside"
@dragover="onDragOver"
@ -14,38 +13,51 @@
@mouseup="onMouseUp"
data-test-id="node-creator"
>
<main-panel @nodeTypeSelected="$listeners.nodeTypeSelected" />
<NodesListPanel @nodeTypeSelected="$listeners.nodeTypeSelected" />
</div>
</slide-transition>
</div>
</template>
<script setup lang="ts">
import { watch, reactive, toRefs } from 'vue';
import { watch, reactive, toRefs, computed } from 'vue';
import { useNodeTypesStore } from '@/stores/nodeTypes';
import { useNodeCreatorStore } from '@/stores/nodeCreator';
import SlideTransition from '@/components/transitions/SlideTransition.vue';
import MainPanel from './MainPanel.vue';
import { useNodeCreatorStore } from '@/stores/nodeCreator';
import { useViewStacks } from './composables/useViewStacks';
import { useKeyboardNavigation } from './composables/useKeyboardNavigation';
import { useActionsGenerator } from './composables/useActionsGeneration';
import NodesListPanel from './Panel/NodesListPanel.vue';
export interface Props {
active?: boolean;
}
const props = defineProps<Props>();
const { resetViewStacks } = useViewStacks();
const { registerKeyHook } = useKeyboardNavigation();
const emit = defineEmits<{
(event: 'closeNodeCreator'): void;
(event: 'nodeTypeSelected', value: string[]): void;
}>();
const nodeCreatorStore = useNodeCreatorStore();
const { setShowScrim, setActions, setMergeNodes } = useNodeCreatorStore();
const { generateMergedNodesAndActions } = useActionsGenerator();
const state = reactive({
nodeCreator: null as HTMLElement | null,
mousedownInsideEvent: null as MouseEvent | null,
});
const showScrim = computed(() => useNodeCreatorStore().showScrim);
const viewStacksLength = computed(() => useViewStacks().viewStacks.length);
function onClickOutside(event: Event) {
// We need to prevent cases where user would click inside the node creator
// and try to drag undraggable element. In that case the click event would
// and try to drag non-draggable element. In that case the click event would
// be fired and the node creator would be closed. So we stop that if we detect
// that the click event originated from inside the node creator. And fire click even on the
// original target.
@ -93,21 +105,49 @@ function onDrop(event: DragEvent) {
watch(
() => props.active,
(isActive) => {
if (isActive === false) nodeCreatorStore.setShowScrim(false);
if (isActive === false) {
setShowScrim(false);
resetViewStacks();
}
},
);
// Close node creator when the last view stacks is closed
watch(viewStacksLength, (viewStacksLength) => {
if (viewStacksLength === 0) {
emit('closeNodeCreator');
setShowScrim(false);
}
});
registerKeyHook('NodeCreatorCloseEscape', {
keyboardKeys: ['Escape'],
handler: () => emit('closeNodeCreator'),
});
registerKeyHook('NodeCreatorCloseTab', {
keyboardKeys: ['Tab'],
handler: () => emit('closeNodeCreator'),
});
watch(
() => useNodeTypesStore().visibleNodeTypes,
(nodeTypes) => {
const { actions, mergedNodes } = generateMergedNodesAndActions(nodeTypes);
setActions(actions);
setMergeNodes(mergedNodes);
},
{ immediate: true },
);
const { nodeCreator } = toRefs(state);
</script>
<style scoped lang="scss">
::v-deep *,
*:before,
*:after {
box-sizing: border-box;
<style module lang="scss">
:global(strong) {
font-weight: var(--font-weight-bold);
}
.node-creator {
.nodeCreator {
--node-icon-color: var(--color-text-base);
position: fixed;
top: $header-height;
bottom: 0;
@ -117,7 +157,7 @@ const { nodeCreator } = toRefs(state);
color: $node-creator-text-color;
}
.node-creator-scrim {
.nodeCreatorScrim {
position: fixed;
top: $header-height;
right: 0;

View file

@ -1,12 +1,24 @@
<template>
<div :class="{ [$style.noResults]: true, [$style.iconless]: !showIcon }">
<div
:class="{ [$style.noResults]: true, [$style.iconless]: !showIcon }"
data-test-id="node-creator-no-results"
>
<div :class="$style.icon" v-if="showIcon">
<no-results-icon />
</div>
<div :class="$style.title">
<slot name="title" />
<p v-text="$locale.baseText('nodeCreator.noResults.weDidntMakeThatYet')" />
<div :class="$style.action">
<slot name="action" />
{{ $locale.baseText('nodeCreator.noResults.dontWorryYouCanProbablyDoItWithThe') }}
<n8n-link v-if="rootView === REGULAR_NODE_CREATOR_VIEW" @click="$emit('addHttpNode')">
{{ $locale.baseText('nodeCreator.noResults.httpRequest') }}
</n8n-link>
<n8n-link v-if="rootView === TRIGGER_NODE_CREATOR_VIEW" @click="$emit('addWebhookNode')">
{{ $locale.baseText('nodeCreator.noResults.webhook') }}
</n8n-link>
{{ $locale.baseText('nodeCreator.noResults.node') }}
</div>
</div>
@ -30,12 +42,19 @@
</template>
<script setup lang="ts">
import { REQUEST_NODE_FORM_URL } from '@/constants';
import {
REQUEST_NODE_FORM_URL,
REGULAR_NODE_CREATOR_VIEW,
TRIGGER_NODE_CREATOR_VIEW,
} from '@/constants';
import type { NodeFilterType } from '@/Interface';
import NoResultsIcon from './NoResultsIcon.vue';
export interface Props {
showIcon?: boolean;
showRequest?: boolean;
rootView?: NodeFilterType;
}
defineProps<Props>();

View file

@ -0,0 +1,258 @@
<script setup lang="ts">
import { getCurrentInstance, computed, onMounted, onUnmounted, watch } from 'vue';
import type { INodeCreateElement } from '@/Interface';
import { TRIGGER_NODE_CREATOR_VIEW } from '@/constants';
import { useNodeCreatorStore } from '@/stores/nodeCreator';
import { TriggerView, RegularView } from '../viewsData';
import { useViewStacks } from '../composables/useViewStacks';
import { useKeyboardNavigation } from '../composables/useKeyboardNavigation';
import SearchBar from './SearchBar.vue';
import ActionsRenderer from '../Modes/ActionsMode.vue';
import NodesRenderer from '../Modes/NodesMode.vue';
const instance = getCurrentInstance();
const { mergedNodes } = useNodeCreatorStore();
const { pushViewStack, popViewStack, updateCurrentViewStack } = useViewStacks();
const { setActiveItemIndex, attachKeydownEvent, detachKeydownEvent } = useKeyboardNavigation();
const activeViewStack = computed(() => useViewStacks().activeViewStack);
const viewStacks = computed(() => useViewStacks().viewStacks);
const isActionsMode = computed(() => useViewStacks().activeViewStackMode === 'actions');
const searchPlaceholder = computed(() =>
isActionsMode.value
? instance?.proxy?.$locale.baseText('nodeCreator.actionsCategory.searchActions', {
interpolate: { node: activeViewStack.value.title as string },
})
: instance?.proxy?.$locale.baseText('nodeCreator.searchBar.searchNodes'),
);
const nodeCreatorView = computed(() => useNodeCreatorStore().selectedView);
function onSearch(value: string) {
if (activeViewStack.value.uuid) {
updateCurrentViewStack({ search: value });
setActiveItemIndex(activeViewStack.value.activeIndex ?? 0);
}
}
function onTransitionEnd() {
// For actions, set the active focus to the first action, not category
const newStackIndex = activeViewStack.value.mode === 'actions' ? 1 : 0;
setActiveItemIndex(activeViewStack.value.activeIndex || 0 || newStackIndex);
}
onMounted(() => {
attachKeydownEvent();
setActiveItemIndex(activeViewStack.value.activeIndex ?? 0);
});
onUnmounted(() => {
detachKeydownEvent();
});
watch(
() => nodeCreatorView.value,
(selectedView) => {
const view =
selectedView === TRIGGER_NODE_CREATOR_VIEW
? TriggerView(instance?.proxy?.$locale)
: RegularView(instance?.proxy?.$locale);
pushViewStack({
title: view.title,
subtitle: view?.subtitle ?? '',
items: view.items as INodeCreateElement[],
hasSearch: true,
mode: 'nodes',
rootView: selectedView,
// Root search should include all nodes
searchItems: mergedNodes,
});
},
{ immediate: true },
);
function onBackButton() {
popViewStack();
}
</script>
<template>
<transition
v-if="viewStacks.length > 0"
:name="`panel-slide-${activeViewStack.transitionDirection}`"
@afterLeave="onTransitionEnd"
>
<aside :class="$style.nodesListPanel" @keydown.capture.stop :key="`${activeViewStack.uuid}`">
<header
:class="{ [$style.header]: true, [$style.hasBg]: !activeViewStack.subtitle }"
data-test-id="nodes-list-header"
>
<div :class="$style.top">
<button :class="$style.backButton" @click="onBackButton" v-if="viewStacks.length > 1">
<font-awesome-icon :class="$style.backButtonIcon" icon="arrow-left" size="2x" />
</button>
<n8n-node-icon
v-if="activeViewStack.nodeIcon"
:class="$style.nodeIcon"
:type="activeViewStack.nodeIcon.iconType || 'unknown'"
:src="activeViewStack.nodeIcon.icon"
:name="activeViewStack.nodeIcon.icon"
:color="activeViewStack.nodeIcon.color"
:circle="false"
:showTooltip="false"
:size="16"
/>
<p :class="$style.title" v-text="activeViewStack.title" v-if="activeViewStack.title" />
</div>
<p
v-if="activeViewStack.subtitle"
:class="{ [$style.subtitle]: true, [$style.offsetSubtitle]: viewStacks.length > 1 }"
v-text="activeViewStack.subtitle"
/>
</header>
<search-bar
v-if="activeViewStack.hasSearch"
:class="$style.searchBar"
:placeholder="
searchPlaceholder
? searchPlaceholder
: $locale.baseText('nodeCreator.searchBar.searchNodes')
"
@input="onSearch"
:value="activeViewStack.search"
/>
<div :class="$style.renderedItems">
<!-- Actions mode -->
<ActionsRenderer v-if="isActionsMode && activeViewStack.subcategory" v-on="$listeners" />
<!-- Nodes Mode -->
<NodesRenderer v-else :rootView="nodeCreatorView" v-on="$listeners" />
</div>
</aside>
</transition>
</template>
<style lang="scss" module>
:global(.panel-slide-in-leave-active),
:global(.panel-slide-in-enter-active),
:global(.panel-slide-out-leave-active),
:global(.panel-slide-out-enter-active) {
transition: transform 200ms ease;
position: absolute;
left: 0;
right: 0;
}
:global(.panel-slide-out-enter),
:global(.panel-slide-in-leave-to) {
transform: translateX(0);
z-index: -1;
}
:global(.panel-slide-out-leave-to),
:global(.panel-slide-in-enter) {
transform: translateX(100%);
// Make sure the leaving panel stays on top
// for the slide-out panel effect
z-index: 1;
}
.backButton {
background: transparent;
border: none;
cursor: pointer;
padding: 0 var(--spacing-xs) 0 0;
}
.backButtonIcon {
color: $node-creator-arrow-color;
height: 16px;
padding: 0;
}
.nodeIcon {
--node-icon-size: 16px;
margin-right: var(--spacing-s);
}
.renderedItems {
overflow: auto;
height: 100%;
display: flex;
flex-direction: column;
scrollbar-width: none; /* Firefox 64 */
padding-bottom: var(--spacing-xl);
&::-webkit-scrollbar {
display: none;
}
}
.searchBar {
flex-shrink: 0;
}
.nodesListPanel {
background: var(--color-background-xlight);
height: 100%;
background-color: $node-creator-background-color;
width: 385px;
display: flex;
flex-direction: column;
&:before {
box-sizing: border-box;
content: '';
border-left: 1px solid $node-creator-border-color;
width: 1px;
position: absolute;
height: 100%;
}
}
.footer {
font-size: var(--font-size-2xs);
color: var(--color-text-base);
margin: 0 var(--spacing-xs) 0;
padding: var(--spacing-4xs) 0;
line-height: var(--font-line-height-regular);
border-top: 1px solid var(--color-foreground-base);
z-index: 1;
margin-top: -1px;
}
.top {
display: flex;
align-items: center;
}
.header {
font-size: var(--font-size-l);
font-weight: var(--font-weight-bold);
line-height: var(--font-line-height-compact);
padding: var(--spacing-s) var(--spacing-s);
&.hasBg {
border-bottom: $node-creator-border-color solid 1px;
background-color: $node-creator-subcategory-panel-header-bacground-color;
}
}
.title {
line-height: 24px;
font-weight: var(--font-weight-bold);
font-size: var(--font-size-l);
.hasBg & {
font-size: var(--font-size-s-m);
line-height: 22px;
}
}
.subtitle {
margin-top: var(--spacing-4xs);
font-size: var(--font-size-s);
line-height: 19px;
color: var(--color-text-base);
font-weight: var(--font-weight-regular);
}
.offsetSubtitle {
margin-left: calc(var(--spacing-xl) + var(--spacing-4xs));
}
</style>

View file

@ -25,13 +25,12 @@
<script setup lang="ts">
import { onMounted, reactive, toRefs, onBeforeUnmount } from 'vue';
import type { EventBus } from '@/event-bus';
import { useExternalHooks } from '@/composables';
import { useWebhooksStore } from '@/stores/webhooks';
import { runExternalHook } from '@/utils';
export interface Props {
placeholder: string;
value: string;
eventBus?: EventBus;
}
withDefaults(defineProps<Props>(), {
@ -43,8 +42,6 @@ const emit = defineEmits<{
(event: 'input', value: string): void;
}>();
const externalHooks = useExternalHooks();
const state = reactive({
inputRef: null as HTMLInputElement | null,
});
@ -63,7 +60,7 @@ function clear() {
}
onMounted(() => {
externalHooks.run('nodeCreator_searchBar.mount', { inputRef: state.inputRef });
runExternalHook('nodeCreator_searchBar.mount', useWebhooksStore(), { inputRef: state.inputRef });
setTimeout(focus, 0);
});

View file

@ -0,0 +1,153 @@
<script setup lang="ts">
import { computed, watch, ref, getCurrentInstance } from 'vue';
import type { INodeCreateElement } from '@/Interface';
import { useWorkflowsStore } from '@/stores/workflows';
import { useKeyboardNavigation } from '../composables/useKeyboardNavigation';
import { useViewStacks } from '../composables/useViewStacks';
import ItemsRenderer from './ItemsRenderer.vue';
import CategoryItem from '../ItemTypes/CategoryItem.vue';
export interface Props {
elements: INodeCreateElement[];
category: string;
disabled?: boolean;
activeIndex?: number;
isTriggerCategory?: boolean;
mouseOverTooltip?: string;
expanded?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
elements: () => [],
});
const instance = getCurrentInstance();
const { popViewStack } = useViewStacks();
const { registerKeyHook } = useKeyboardNavigation();
const { workflowId } = useWorkflowsStore();
const activeItemId = computed(() => useKeyboardNavigation()?.activeItemId);
const expanded = ref(props.expanded ?? false);
function toggleExpanded() {
setExpanded(!expanded.value);
}
function setExpanded(isExpanded: boolean) {
expanded.value = isExpanded;
if (expanded.value) {
instance?.proxy.$telemetry.trackNodesPanel('nodeCreateList.onCategoryExpanded', {
category_name: props.category,
workflow_id: workflowId,
});
}
}
function arrowRight() {
if (expanded.value) return;
setExpanded(true);
}
function arrowLeft() {
if (!expanded.value) {
popViewStack();
return;
}
setExpanded(false);
}
watch(
() => props.elements,
() => {
setExpanded(true);
},
);
registerKeyHook(`CategoryRight_${props.category}`, {
keyboardKeys: ['ArrowRight'],
condition: (type, activeItemId) => type === 'category' && props.category === activeItemId,
handler: arrowRight,
});
registerKeyHook(`CategoryToggle_${props.category}`, {
keyboardKeys: ['Enter'],
condition: (type, activeItemId) => type === 'category' && props.category === activeItemId,
handler: toggleExpanded,
});
registerKeyHook(`CategoryLeft_${props.category}`, {
keyboardKeys: ['ArrowLeft'],
condition: (type, activeItemId) => type === 'category' && props.category === activeItemId,
handler: arrowLeft,
});
</script>
<template>
<div :class="$style.categorizedItemsRenderer" :data-category-collapsed="!expanded">
<CategoryItem
:class="$style.categoryItem"
:name="category"
:disabled="disabled"
:active="activeItemId === category"
:count="elements.length"
:expanded="expanded"
:isTrigger="isTriggerCategory"
data-keyboard-nav-type="category"
:data-keyboard-nav-id="category"
@click="toggleExpanded"
>
<span :class="$style.mouseOverTooltip" v-if="mouseOverTooltip">
<n8n-tooltip placement="top" :popper-class="$style.tooltipPopper">
<n8n-icon icon="question-circle" size="small" />
<template #content>
<div v-html="mouseOverTooltip" />
</template>
</n8n-tooltip>
</span>
</CategoryItem>
<div :class="$style.contentSlot" v-if="expanded && elements.length > 0 && $slots.default">
<slot />
</div>
<!-- Pass through listeners & empty slot to ItemsRenderer -->
<ItemsRenderer
v-if="expanded"
:elements="elements"
v-on="$listeners"
:isTrigger="isTriggerCategory"
>
<template #default> </template>
<template #empty>
<slot name="empty" v-bind="{ elements }" />
</template>
</ItemsRenderer>
</div>
</template>
<style lang="scss" module>
.mouseOverTooltip {
opacity: 0;
margin-left: var(--spacing-3xs);
&:hover {
color: var(--color-primary);
}
.categorizedItemsRenderer:hover & {
opacity: 1;
}
}
.tooltipPopper {
max-width: 260px;
}
.contentSlot {
padding: 0 var(--spacing-s) var(--spacing-3xs);
margin-top: var(--spacing-xs);
}
.categorizedItemsRenderer {
padding-bottom: var(--spacing-s);
}
</style>

View file

@ -0,0 +1,215 @@
<script setup lang="ts">
import type { INodeCreateElement } from '@/Interface';
import { onMounted, watch, onUnmounted, ref, computed } from 'vue';
import { useKeyboardNavigation } from '../composables/useKeyboardNavigation';
import NodeItem from '../ItemTypes/NodeItem.vue';
import SubcategoryItem from '../ItemTypes/SubcategoryItem.vue';
import LabelItem from '../ItemTypes/LabelItem.vue';
import ActionItem from '../ItemTypes/ActionItem.vue';
import ViewItem from '../ItemTypes/ViewItem.vue';
export interface Props {
elements: INodeCreateElement[];
activeIndex?: number;
disabled?: boolean;
lazyRender?: boolean;
}
const LAZY_LOAD_THRESHOLD = 20;
const LAZY_LOAD_ITEMS_PER_TICK = 5;
const props = withDefaults(defineProps<Props>(), {
elements: () => [],
lazyRender: true,
});
const emit = defineEmits<{
(event: 'selected', element: INodeCreateElement, $e?: Event): void;
(event: 'dragstart', element: INodeCreateElement, $e: Event): void;
(event: 'dragend', element: INodeCreateElement, $e: Event): void;
}>();
const renderedItems = ref<INodeCreateElement[]>([]);
const renderAnimationRequest = ref<number>(0);
const activeItemId = computed(() => useKeyboardNavigation()?.activeItemId);
// Lazy render large items lists to prevent the browser from freezing
// when loading many items.
function renderItems() {
if (props.elements.length <= LAZY_LOAD_THRESHOLD || props.lazyRender === false) {
renderedItems.value = props.elements;
return;
}
if (renderedItems.value.length < props.elements.length) {
renderedItems.value.push(
...props.elements.slice(
renderedItems.value.length,
renderedItems.value.length + LAZY_LOAD_ITEMS_PER_TICK,
),
);
renderAnimationRequest.value = window.requestAnimationFrame(renderItems);
}
}
function wrappedEmit(
event: 'selected' | 'dragstart' | 'dragend',
element: INodeCreateElement,
$e?: Event,
) {
if (props.disabled) return;
emit((event as 'selected') || 'dragstart' || 'dragend', element, $e);
}
function beforeEnter(el: HTMLElement) {
el.style.height = '0';
}
function enter(el: HTMLElement) {
el.style.height = `${el.scrollHeight}px`;
}
function beforeLeave(el: HTMLElement) {
el.style.height = `${el.scrollHeight}px`;
}
function leave(el: HTMLElement) {
el.style.height = '0';
}
onMounted(() => {
renderItems();
});
onUnmounted(() => {
window.cancelAnimationFrame(renderAnimationRequest.value);
renderedItems.value = [];
});
// Make sure the active item is always visible
// scroll if needed
watch(
() => props.elements,
() => {
window.cancelAnimationFrame(renderAnimationRequest.value);
renderedItems.value = [];
renderItems();
},
);
</script>
<template>
<div
v-if="elements.length > 0"
:class="$style.itemsRenderer"
name="accordion"
@before-enter="beforeEnter"
@enter="enter"
@before-leave="beforeLeave"
@leave="leave"
>
<slot />
<div
v-for="item in elements"
:key="item.uuid"
data-test-id="item-iterator-item"
:class="{
clickable: !disabled,
[$style.active]: activeItemId === item.uuid,
[$style.iteratorItem]: true,
[$style[item.type]]: true,
}"
ref="iteratorItems"
:data-keyboard-nav-type="item.type !== 'label' ? item.type : undefined"
:data-keyboard-nav-id="item.uuid"
@click="wrappedEmit('selected', item)"
>
<template v-if="renderedItems.includes(item)">
<label-item v-if="item.type === 'label'" :item="item" />
<subcategory-item v-if="item.type === 'subcategory'" :item="item.properties" />
<node-item
v-if="item.type === 'node'"
:nodeType="item.properties"
:active="true"
:subcategory="item.subcategory"
/>
<action-item
v-if="item.type === 'action'"
:nodeType="item.properties"
:action="item.properties"
:active="true"
/>
<view-item
v-else-if="item.type === 'view'"
:view="item.properties"
:class="$style.viewItem"
/>
</template>
<n8n-loading :loading="true" :rows="1" variant="p" :class="$style.itemSkeleton" v-else />
</div>
</div>
<div :class="$style.empty" v-else>
<slot name="empty" />
</div>
</template>
<style lang="scss" module>
.itemSkeleton {
height: 50px;
}
.iteratorItem {
// Make sure border is fully visible
margin-left: 1px;
position: relative;
&::before {
content: '';
position: absolute;
left: -1px;
top: 0;
bottom: 0;
border-left: 2px solid transparent;
}
&:not(.label):not(.category):hover::before {
border-color: $node-creator-item-hover-border-color;
}
&.active:not(.category)::before {
border-color: $color-primary;
}
}
.empty {
:global([role='alert']) {
margin: var(--spacing-xs) var(--spacing-s);
}
}
.itemsRenderer {
display: flex;
flex-direction: column;
scrollbar-width: none; /* Firefox 64 */
& > *::-webkit-scrollbar {
display: none;
}
}
.view {
margin-top: var(--spacing-s);
padding-top: var(--spacing-xs);
position: relative;
&::after {
content: '';
position: absolute;
left: var(--spacing-s);
right: var(--spacing-s);
top: 0;
margin: auto;
bottom: 0;
border-top: 1px solid var(--color-foreground-base);
}
}
</style>

View file

@ -0,0 +1,31 @@
import { render, screen } from '@testing-library/vue';
import CategoryItem from '../ItemTypes/CategoryItem.vue';
describe('CategoryItem', () => {
it('should allow expand and collapse', async () => {
const { container, updateProps } = render(CategoryItem, { props: { name: 'Category Test' } });
expect(container.querySelector('[data-icon="chevron-down"]')).toBeInTheDocument();
await updateProps({ expanded: false });
expect(container.querySelector('[data-icon="chevron-down"]')).not.toBeInTheDocument();
expect(container.querySelector('[data-icon="chevron-up"]')).toBeInTheDocument();
});
it('should show count', async () => {
const { updateProps } = render(CategoryItem, { props: { name: 'Category Test', count: 10 } });
expect(screen.getByText('Category Test (10)')).toBeInTheDocument();
await updateProps({ count: 0 });
expect(screen.getByText('Category Test')).toBeInTheDocument();
});
it('should show trigger icon', async () => {
const { updateProps, container } = render(CategoryItem, {
props: { name: 'Category Test', isTrigger: true },
});
expect(container.querySelector('[data-icon="bolt"]')).toBeInTheDocument();
await updateProps({ isTrigger: false });
expect(container.querySelector('[data-icon="bolt"]')).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,82 @@
import Vue from 'vue';
import { PiniaVuePlugin } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { render, fireEvent } from '@testing-library/vue';
import {
mockSubcategoryCreateElement,
mockLabelCreateElement,
mockNodeCreateElement,
} from './utils';
import ItemsRenderer from '../Renderers/ItemsRenderer.vue';
import { mockActionCreateElement } from './utils';
import { mockViewCreateElement } from './utils';
describe('ItemsRenderer', () => {
it('should render items', async () => {
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' }),
mockLabelCreateElement('subcategory', { key: 'label2' }),
mockNodeCreateElement('subcategory', { displayName: 'Node 2', name: 'node2' }),
mockNodeCreateElement('subcategory', { displayName: 'Node 3', name: 'node3' }),
mockSubcategoryCreateElement({ title: 'Subcategory 2' }),
];
const { container } = render(
ItemsRenderer,
{
pinia: createTestingPinia(),
props: { elements: items },
},
(vue) => {
vue.use(PiniaVuePlugin);
},
);
//
await Vue.nextTick();
const nodeItems = container.querySelectorAll('.iteratorItem .nodeItem');
const labels = container.querySelectorAll('.iteratorItem .label');
const subCategories = container.querySelectorAll('.iteratorItem .subCategory');
expect(nodeItems.length).toBe(5);
expect(labels.length).toBe(2);
expect(subCategories.length).toBe(2);
});
it('should fire selected events on click', async () => {
const items = [
mockSubcategoryCreateElement(),
mockNodeCreateElement(),
mockActionCreateElement(),
mockViewCreateElement(),
];
const { container, emitted } = render(
ItemsRenderer,
{
pinia: createTestingPinia(),
props: { elements: items },
},
(vue) => {
vue.use(PiniaVuePlugin);
},
);
//
await Vue.nextTick();
const itemTypes = {
node: container.querySelector('.iteratorItem .nodeItem'),
subcategory: container.querySelector('.iteratorItem .subCategory'),
action: container.querySelector('.iteratorItem .action'),
view: container.querySelector('.iteratorItem .view'),
};
for (const [index, itemType] of Object.keys(itemTypes).entries()) {
const itemElement = itemTypes[itemType as keyof typeof itemTypes];
await fireEvent.click(itemElement!);
expect(emitted().selected[index][0].type).toBe(itemType);
}
});
});

View file

@ -0,0 +1,286 @@
import Vue, { defineComponent, watch } from 'vue';
import type { PropType } from 'vue';
import { PiniaVuePlugin, createPinia } from 'pinia';
import { render, screen, fireEvent } from '@testing-library/vue';
import type { INodeTypeDescription } from 'n8n-workflow';
import { useNodeCreatorStore } from '@/stores/nodeCreator';
import { mockSimplifiedNodeType } from './utils';
import NodesListPanel from '../Panel/NodesListPanel.vue';
import { REGULAR_NODE_CREATOR_VIEW } from '@/constants';
import type { NodeFilterType } from '@/Interface';
function TelemetryPlugin(vue: typeof Vue): void {
Object.defineProperty(vue, '$telemetry', {
get() {
return {
trackNodesPanel: () => {},
};
},
});
Object.defineProperty(vue.prototype, '$telemetry', {
get() {
return {
trackNodesPanel: () => {},
};
},
});
}
function getWrapperComponent(setup: () => void) {
const wrapperComponent = defineComponent({
props: {
nodeTypes: {
type: Array as PropType<INodeTypeDescription[]>,
required: false,
},
},
components: {
NodesListPanel,
},
setup,
template: '<NodesListPanel @nodeTypeSelected="e => $emit(\'nodeTypeSelected\', e)" />',
});
return render(
wrapperComponent,
{
pinia: createPinia(),
},
(vue) => {
vue.use(PiniaVuePlugin);
vue.use(TelemetryPlugin);
},
);
}
describe('NodesListPanel', () => {
describe('should render nodes', () => {
it('should render trigger items', async () => {
const mockedTriggerNodes = [...Array(2).keys()].map((n) =>
mockSimplifiedNodeType({
name: `Trigger Node ${n}`,
displayName: `Trigger Node ${n}`,
group: ['trigger'],
}),
);
const mockedRegularNodes = [...Array(2).keys()].map((n) =>
mockSimplifiedNodeType({
name: `Regular Node ${n}`,
displayName: `Regular Node ${n}`,
group: ['input'],
}),
);
const { container } = getWrapperComponent(() => {
const { setMergeNodes } = useNodeCreatorStore();
setMergeNodes([...mockedTriggerNodes, ...mockedRegularNodes]);
return {};
});
await Vue.nextTick();
expect(screen.getByText('Select a trigger')).toBeInTheDocument();
expect(screen.queryByTestId('node-creator-search-bar')).toBeInTheDocument();
screen.getByText('On app event').click();
await Vue.nextTick();
expect(screen.queryByTestId('node-creator-search-bar')).not.toBeInTheDocument();
mockedTriggerNodes.forEach((n) => {
expect(screen.queryByText(n.name)).toBeInTheDocument();
});
mockedRegularNodes.forEach((n) => {
expect(screen.queryByText(n.name)).not.toBeInTheDocument();
});
expect(container.querySelector('.backButton')).toBeInTheDocument();
fireEvent.click(container.querySelector('.backButton')!);
await Vue.nextTick();
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(6);
});
it('should render regular nodes', async () => {
const mockedNodes = [...Array(8).keys()].map((n) =>
mockSimplifiedNodeType({
name: `Node ${n}`,
displayName: `Node ${n}`,
group: ['input'],
}),
);
const wrapperComponent = defineComponent({
props: {
nodeTypes: {
type: Array as PropType<INodeTypeDescription[]>,
required: true,
},
selectedView: {
type: String as PropType<NodeFilterType>,
default: REGULAR_NODE_CREATOR_VIEW,
required: false,
},
},
components: {
NodesListPanel,
},
setup(props) {
const { setActions, setMergeNodes, setSelectedView } = useNodeCreatorStore();
watch(
() => props.nodeTypes,
(nodeTypes: INodeTypeDescription[]) => {
setMergeNodes([...nodeTypes]);
},
{ immediate: true },
);
watch(
() => props.selectedView,
(selectedView: NodeFilterType) => {
setSelectedView(selectedView);
},
{ immediate: true },
);
},
template: '<NodesListPanel @nodeTypeSelected="e => $emit(\'nodeTypeSelected\', e)" />',
});
render(
wrapperComponent,
{
pinia: createPinia(),
props: {
nodeTypes: mockedNodes,
selectedView: REGULAR_NODE_CREATOR_VIEW,
},
},
(vue) => {
vue.use(PiniaVuePlugin);
vue.use(TelemetryPlugin);
},
);
await Vue.nextTick();
expect(screen.getByText('What happens next?')).toBeInTheDocument();
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(6);
screen.getByText('Action in an app').click();
await Vue.nextTick();
mockedNodes.forEach((n) => {
expect(screen.queryByText(n.displayName)).toBeInTheDocument();
});
});
});
describe('should search nodes', () => {
const mockedNodes = [...Array(8).keys()].map((n) =>
mockSimplifiedNodeType({
name: `Node ${n}`,
displayName: `Node ${n}`,
group: ['trigger'],
}),
);
const wrapperComponent = defineComponent({
props: {
nodeTypes: {
type: Array as PropType<INodeTypeDescription[]>,
required: true,
},
},
components: {
NodesListPanel,
},
setup(props) {
const { setMergeNodes } = useNodeCreatorStore();
watch(
() => props.nodeTypes,
(nodeTypes: INodeTypeDescription[]) => {
setMergeNodes([...nodeTypes]);
},
{ immediate: true },
);
},
template: '<NodesListPanel @nodeTypeSelected="e => $emit(\'nodeTypeSelected\', e)" />',
});
function renderComponent() {
return render(
wrapperComponent,
{
pinia: createPinia(),
props: {
nodeTypes: mockedNodes,
},
},
(vue) => {
vue.use(PiniaVuePlugin);
vue.use(TelemetryPlugin);
},
);
}
it('should be visible in the root view', async () => {
renderComponent();
await Vue.nextTick();
expect(screen.queryByTestId('node-creator-search-bar')).toBeInTheDocument();
});
it('should not be visible if subcategory contains less than 9 items', async () => {
renderComponent();
await Vue.nextTick();
screen.getByText('On app event').click();
await Vue.nextTick();
expect(screen.queryByTestId('node-creator-search-bar')).not.toBeInTheDocument();
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(8);
});
it('should be visible if subcategory contains 9 or more items', async () => {
const { updateProps } = renderComponent();
await Vue.nextTick();
mockedNodes.push(
mockSimplifiedNodeType({
name: 'Ninth node',
displayName: 'Ninth node',
group: ['trigger'],
}),
);
await updateProps({ nodeTypes: [...mockedNodes] });
await Vue.nextTick();
screen.getByText('On app event').click();
await Vue.nextTick();
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(9);
expect(screen.queryByTestId('node-creator-search-bar')).toBeInTheDocument();
});
it('should correctly handle search', async () => {
const { container } = renderComponent();
await Vue.nextTick();
screen.getByText('On app event').click();
await Vue.nextTick();
fireEvent.input(screen.getByTestId('node-creator-search-bar'), {
target: { value: 'Ninth' },
});
await Vue.nextTick();
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(1);
fireEvent.input(screen.getByTestId('node-creator-search-bar'), {
target: { value: 'Non sense' },
});
await Vue.nextTick();
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(0);
expect(screen.queryByText("We didn't make that... yet")).toBeInTheDocument();
fireEvent.click(container.querySelector('.clear')!);
await Vue.nextTick();
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(9);
});
});
});

View file

@ -0,0 +1,90 @@
import { render } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { defineComponent, computed } from 'vue';
import { useKeyboardNavigation } from '../composables/useKeyboardNavigation';
import { PiniaVuePlugin, createPinia } from 'pinia';
const eventHookSpy = vi.fn();
describe('useKeyboardNavigation', () => {
const TestComponent = defineComponent({
setup() {
const { attachKeydownEvent, setActiveItemId, detachKeydownEvent, registerKeyHook } =
useKeyboardNavigation();
setActiveItemId('item1');
const activeItemId = computed(() => useKeyboardNavigation().activeItemId);
registerKeyHook('testKeys', {
keyboardKeys: ['ArrowDown', 'ArrowUp', 'Enter', 'ArrowRight', 'ArrowLeft', 'Escape'],
handler: eventHookSpy,
});
return { attachKeydownEvent, detachKeydownEvent, setActiveItemId, activeItemId };
},
template: `
<span>
<div
v-for="item in 3"
:class="{'active': activeItemId === 'item' + item}"
:key="'item' + item"
v-text="activeItemId"
:data-keyboard-nav-id="'item' + item"
data-keyboard-nav-type="node"
/>
</span>
`,
mounted() {
this.attachKeydownEvent();
},
});
const renderTestComponent = () => {
return render(TestComponent, { pinia: createPinia() }, (vue) => {
vue.use(PiniaVuePlugin);
});
};
afterAll(() => {
eventHookSpy.mockClear();
});
test('ArrowDown moves to the next item, cycling after last item', async () => {
const { container } = renderTestComponent();
expect(container.querySelector('[data-keyboard-nav-id="item1"]')).toHaveClass('active');
await userEvent.keyboard('{arrowdown}');
await userEvent.keyboard('{arrowdown}');
expect(container.querySelector('[data-keyboard-nav-id="item3"]')).toHaveClass('active');
await userEvent.keyboard('{arrowdown}');
expect(container.querySelector('[data-keyboard-nav-id="item1"]')).toHaveClass('active');
});
test('ArrowUp moves to the previous item, cycling after firstitem', async () => {
const { container } = renderTestComponent();
expect(container.querySelector('[data-keyboard-nav-id="item1"]')).toHaveClass('active');
await userEvent.keyboard('{arrowup}');
expect(container.querySelector('[data-keyboard-nav-id="item3"]')).toHaveClass('active');
await userEvent.keyboard('{arrowup}');
expect(container.querySelector('[data-keyboard-nav-id="item2"]')).toHaveClass('active');
await userEvent.keyboard('{arrowup}');
expect(container.querySelector('[data-keyboard-nav-id="item1"]')).toHaveClass('active');
});
test('Key hooks are executed', async () => {
renderTestComponent();
await userEvent.keyboard('{arrowup}');
expect(eventHookSpy).toHaveBeenCalledWith('item3', 'ArrowUp');
await userEvent.keyboard('{arrowdown}');
expect(eventHookSpy).toHaveBeenCalledWith('item1', 'ArrowDown');
await userEvent.keyboard('{arrowleft}');
expect(eventHookSpy).toHaveBeenCalledWith('item1', 'ArrowLeft');
await userEvent.keyboard('{arrowright}');
expect(eventHookSpy).toHaveBeenCalledWith('item1', 'ArrowRight');
await userEvent.keyboard('{enter}');
expect(eventHookSpy).toHaveBeenCalledWith('item1', 'Enter');
await userEvent.keyboard('{escape}');
expect(eventHookSpy).toHaveBeenCalledWith('item1', 'Escape');
});
});

View file

@ -0,0 +1,126 @@
import type {
SimplifiedNodeType,
ActionTypeDescription,
SubcategoryItemProps,
ViewItemProps,
LabelItemProps,
NodeCreateElement,
SubcategoryCreateElement,
ViewCreateElement,
LabelCreateElement,
ActionCreateElement,
} from '@/Interface';
import { v4 as uuidv4 } from 'uuid';
export const mockSimplifiedNodeType = (
overrides?: Partial<SimplifiedNodeType>,
): SimplifiedNodeType => ({
displayName: 'Sample DisplayName',
name: 'sampleName',
icon: 'sampleIcon',
iconUrl: 'https://example.com/icon.png',
group: ['group1', 'group2'],
description: 'Sample description',
codex: {
categories: ['category1', 'category2'],
subcategories: {
category1: ['subcategory1', 'subcategory2'],
category2: ['subcategory3', 'subcategory4'],
},
alias: ['alias1', 'alias2'],
},
defaults: {
color: '#ffffff',
},
...overrides,
});
export const mockActionTypeDescription = (
overrides?: Partial<ActionTypeDescription>,
): ActionTypeDescription => ({
...mockSimplifiedNodeType(),
values: { value1: 'test', value2: 123 },
actionKey: 'sampleActionKey',
codex: {
label: 'Sample Label',
categories: ['category1', 'category2'],
},
...overrides,
});
const mockSubcategoryItemProps = (
overrides?: Partial<SubcategoryItemProps>,
): SubcategoryItemProps => ({
description: 'Sample description',
iconType: 'sampleIconType',
icon: 'sampleIcon',
title: 'Sample title',
subcategory: 'sampleSubcategory',
defaults: { color: '#ffffff' },
forceIncludeNodes: ['node1', 'node2'],
...overrides,
});
const mockViewItemProps = (overrides?: Partial<ViewItemProps>): ViewItemProps => ({
title: 'Sample title',
description: 'Sample description',
icon: 'sampleIcon',
...overrides,
});
const mockLabelItemProps = (overrides?: Partial<LabelItemProps>): LabelItemProps => ({
key: uuidv4(),
...overrides,
});
export const mockNodeCreateElement = (
subcategory?: string,
overrides?: Partial<SimplifiedNodeType>,
): NodeCreateElement => ({
uuid: uuidv4(),
key: uuidv4(),
type: 'node',
subcategory: subcategory || 'sampleSubcategory',
properties: mockSimplifiedNodeType(overrides),
});
export const mockSubcategoryCreateElement = (
overrides?: Partial<SubcategoryItemProps>,
): SubcategoryCreateElement => ({
uuid: uuidv4(),
key: uuidv4(),
type: 'subcategory',
properties: mockSubcategoryItemProps(overrides),
});
export const mockViewCreateElement = (
overrides?: Partial<ViewCreateElement>,
): ViewCreateElement => ({
uuid: uuidv4(),
key: uuidv4(),
type: 'view',
properties: mockViewItemProps(),
...overrides,
});
export const mockLabelCreateElement = (
subcategory?: string,
overrides?: Partial<LabelItemProps>,
): LabelCreateElement => ({
uuid: uuidv4(),
key: uuidv4(),
type: 'label',
subcategory: subcategory || 'sampleSubcategory',
properties: mockLabelItemProps(overrides),
});
export const mockActionCreateElement = (
subcategory?: string,
overrides?: Partial<ActionTypeDescription>,
): ActionCreateElement => ({
uuid: uuidv4(),
key: uuidv4(),
type: 'action',
subcategory: subcategory || 'sampleSubcategory',
properties: mockActionTypeDescription(overrides),
});

View file

@ -0,0 +1,218 @@
import { getCurrentInstance, computed } from 'vue';
import type { IDataObject, INodeParameters } from 'n8n-workflow';
import type {
ActionTypeDescription,
INodeCreateElement,
IUpdateInformation,
LabelCreateElement,
} from '@/Interface';
import {
MANUAL_TRIGGER_NODE_TYPE,
NODE_CREATOR_OPEN_SOURCES,
SCHEDULE_TRIGGER_NODE_TYPE,
STICKY_NODE_TYPE,
TRIGGER_NODE_CREATOR_VIEW,
WEBHOOK_NODE_TYPE,
} from '@/constants';
import type { BaseTextKey } from '@/plugins/i18n';
import type { Telemetry } from '@/plugins/telemetry';
import { useNodeCreatorStore } from '@/stores/nodeCreator';
import { useWorkflowsStore } from '@/stores/workflows';
import { useNodeTypesStore } from '@/stores/nodeTypes';
import { runExternalHook } from '@/utils';
import { useWebhooksStore } from '@/stores/webhooks';
import { sortNodeCreateElements, transformNodeType } from '../utils';
export const useActions = () => {
const nodeCreatorStore = useNodeCreatorStore();
const instance = getCurrentInstance();
const actionsCategoryLocales = computed(() => {
return {
actions: instance?.proxy.$locale.baseText('nodeCreator.actionsCategory.actions') ?? '',
triggers: instance?.proxy.$locale.baseText('nodeCreator.actionsCategory.triggers') ?? '',
};
});
function getPlaceholderTriggerActions(subcategory: string) {
const nodes = [WEBHOOK_NODE_TYPE, SCHEDULE_TRIGGER_NODE_TYPE];
const matchedNodeTypes = nodeCreatorStore.mergedNodes
.filter((node) => nodes.some((n) => n === node.name))
.map((node) => {
const transformed = transformNodeType(node, subcategory, 'action');
if (transformed.type === 'action') {
const nameBase = node.name.replace('n8n-nodes-base.', '');
const localeKey = `nodeCreator.actionsPlaceholderNode.${nameBase}` as BaseTextKey;
const overwriteLocale = instance?.proxy.$locale.baseText(localeKey) as string;
// If the locale key is not the same as the node name, it means it contain a translation
// and we should use it
if (overwriteLocale !== localeKey) {
transformed.properties.displayName = overwriteLocale;
}
}
return transformed;
});
return matchedNodeTypes;
}
function filterActionsCategory(items: INodeCreateElement[], category: string) {
return items.filter(
(item) => item.type === 'action' && item.properties.codex.categories.includes(category),
);
}
function injectActionsLabels(items: INodeCreateElement[]): INodeCreateElement[] {
const extendedActions = sortNodeCreateElements([...items]);
const labelsSet = new Set<string>();
// Collect unique labels
for (const action of extendedActions) {
if (action.type !== 'action') continue;
const label = action.properties?.codex?.label;
labelsSet.add(label);
}
if (labelsSet.size <= 1) return extendedActions;
// Create a map to store the first index of each label
const firstIndexMap = new Map<string, number>();
// Iterate through the extendedActions to find the first index of each label
for (let i = 0; i < extendedActions.length; i++) {
const action = extendedActions[i];
if (action.type !== 'action') continue;
const label = action.properties?.codex?.label;
if (!firstIndexMap.has(label)) {
firstIndexMap.set(label, i);
}
}
// Keep track of the number of inserted labels
let insertedLabels = 0;
// Create and insert new label objects at the first index of each label
for (const label of labelsSet) {
const newLabel: LabelCreateElement = {
uuid: label,
type: 'label',
key: label,
subcategory: extendedActions[0].key,
properties: {
key: label,
},
};
const insertIndex = firstIndexMap.get(label)! + insertedLabels;
extendedActions.splice(insertIndex, 0, newLabel);
insertedLabels++;
}
return extendedActions;
}
function parseCategoryActions(
actions: INodeCreateElement[],
category: string,
withLabels = true,
) {
const filteredActions = filterActionsCategory(actions, category);
if (withLabels) return injectActionsLabels(filteredActions);
return filteredActions;
}
function getActionData(actionItem: ActionTypeDescription): IUpdateInformation {
const displayOptions = actionItem.displayOptions;
const displayConditions = Object.keys(displayOptions?.show || {}).reduce(
(acc: IDataObject, showCondition: string) => {
acc[showCondition] = displayOptions?.show?.[showCondition]?.[0];
return acc;
},
{},
);
return {
name: actionItem.displayName,
key: actionItem.name as string,
value: { ...actionItem.values, ...displayConditions } as INodeParameters,
};
}
function getNodeTypesWithManualTrigger(nodeType?: string): string[] {
if (!nodeType) return [];
const { selectedView, openSource } = useNodeCreatorStore();
const { workflowTriggerNodes } = useWorkflowsStore();
const isTrigger = useNodeTypesStore().isTriggerNode(nodeType);
const workflowContainsTrigger = workflowTriggerNodes.length > 0;
const isTriggerPanel = selectedView === TRIGGER_NODE_CREATOR_VIEW;
const isStickyNode = nodeType === STICKY_NODE_TYPE;
const singleNodeOpenSources = [
NODE_CREATOR_OPEN_SOURCES.PLUS_ENDPOINT,
NODE_CREATOR_OPEN_SOURCES.NODE_CONNECTION_ACTION,
NODE_CREATOR_OPEN_SOURCES.NODE_CONNECTION_DROP,
];
// If the node creator was opened from the plus endpoint, node connection action, or node connection drop
// then we do not want to append the manual trigger
const isSingleNodeOpenSource = singleNodeOpenSources.includes(openSource);
const shouldAppendManualTrigger =
!isSingleNodeOpenSource &&
!isTrigger &&
!workflowContainsTrigger &&
isTriggerPanel &&
!isStickyNode;
const nodeTypes = shouldAppendManualTrigger ? [MANUAL_TRIGGER_NODE_TYPE, nodeType] : [nodeType];
return nodeTypes;
}
// Hook into addNode action to set the last node parameters & track the action selected
function setAddedNodeActionParameters(
action: IUpdateInformation,
telemetry?: Telemetry,
rootView = '',
) {
const { $onAction: onWorkflowStoreAction } = useWorkflowsStore();
const storeWatcher = onWorkflowStoreAction(
({ name, after, store: { setLastNodeParameters }, args }) => {
if (name !== 'addNode' || args[0].type !== action.key) return;
after(() => {
setLastNodeParameters(action);
if (telemetry) trackActionSelected(action, telemetry, rootView);
// Unsubscribe from the store watcher
storeWatcher();
});
},
);
return storeWatcher;
}
function trackActionSelected(action: IUpdateInformation, telemetry: Telemetry, rootView: string) {
const payload = {
node_type: action.key,
action: action.name,
source_mode: rootView.toLowerCase(),
resource: (action.value as INodeParameters).resource || '',
};
runExternalHook('nodeCreateList.addAction', useWebhooksStore(), payload);
telemetry?.trackNodesPanel('nodeCreateList.addAction', payload);
}
return {
actionsCategoryLocales,
getPlaceholderTriggerActions,
parseCategoryActions,
getNodeTypesWithManualTrigger,
getActionData,
setAddedNodeActionParameters,
};
};

View file

@ -0,0 +1,279 @@
import { startCase } from 'lodash-es';
import type {
INodePropertyCollection,
INodePropertyOptions,
INodeProperties,
INodeTypeDescription,
} from 'n8n-workflow';
import { deepCopy } from 'n8n-workflow';
import { CUSTOM_API_CALL_KEY } from '@/constants';
import type { ActionTypeDescription, SimplifiedNodeType, ActionsRecord } from '@/Interface';
import { i18n } from '@/plugins/i18n';
const PLACEHOLDER_RECOMMENDED_ACTION_KEY = 'placeholder_recommended';
const customNodeActionsParsers: {
[key: string]: (
matchedProperty: INodeProperties,
nodeTypeDescription: INodeTypeDescription,
) => ActionTypeDescription[] | undefined;
} = {
['n8n-nodes-base.hubspotTrigger']: (matchedProperty, nodeTypeDescription) => {
const collection = matchedProperty?.options?.[0] as INodePropertyCollection;
return (collection?.values[0]?.options as INodePropertyOptions[])?.map(
(categoryItem): ActionTypeDescription => ({
...getNodeTypeBase(nodeTypeDescription),
actionKey: categoryItem.value as string,
displayName: i18n.baseText('nodeCreator.actionsCategory.onEvent', {
interpolate: { event: startCase(categoryItem.name) },
}),
description: categoryItem.description || '',
displayOptions: matchedProperty.displayOptions,
values: { eventsUi: { eventValues: [{ name: categoryItem.value }] } },
}),
);
},
};
function getNodeTypeBase(nodeTypeDescription: INodeTypeDescription, label?: string) {
const isTrigger = nodeTypeDescription.group.includes('trigger');
const category = isTrigger
? i18n.baseText('nodeCreator.actionsCategory.triggers')
: i18n.baseText('nodeCreator.actionsCategory.actions');
return {
name: nodeTypeDescription.name,
group: nodeTypeDescription.group,
codex: {
label: label || '',
categories: [category],
},
iconUrl: nodeTypeDescription.iconUrl,
icon: nodeTypeDescription.icon,
defaults: nodeTypeDescription.defaults,
};
}
function operationsCategory(nodeTypeDescription: INodeTypeDescription): ActionTypeDescription[] {
if (!!nodeTypeDescription.properties.find((property) => property.name === 'resource')) return [];
const matchedProperty = nodeTypeDescription.properties.find(
(property) => property.name?.toLowerCase() === 'operation',
);
if (!matchedProperty || !matchedProperty.options) return [];
const filteredOutItems = (matchedProperty.options as INodePropertyOptions[]).filter(
(categoryItem: INodePropertyOptions) => !['*', '', ' '].includes(categoryItem.name),
);
const items = filteredOutItems.map((item: INodePropertyOptions) => ({
...getNodeTypeBase(nodeTypeDescription),
actionKey: item.value as string,
displayName: item.action ?? startCase(item.name),
description: item.description ?? '',
displayOptions: matchedProperty.displayOptions,
values: {
[matchedProperty.name]: matchedProperty.type === 'multiOptions' ? [item.value] : item.value,
},
}));
// Do not return empty category
if (items.length === 0) return [];
return items;
}
function triggersCategory(nodeTypeDescription: INodeTypeDescription): ActionTypeDescription[] {
const matchingKeys = ['event', 'events', 'trigger on'];
const isTrigger = nodeTypeDescription.displayName?.toLowerCase().includes('trigger');
const matchedProperty = nodeTypeDescription.properties.find((property) =>
matchingKeys.includes(property.displayName?.toLowerCase()),
);
if (!isTrigger) return [];
// Inject placeholder action if no events are available
// so user is able to add node to the canvas from the actions panel
if (!matchedProperty || !matchedProperty.options) {
return [
{
...getNodeTypeBase(nodeTypeDescription),
actionKey: PLACEHOLDER_RECOMMENDED_ACTION_KEY,
displayName: i18n.baseText('nodeCreator.actionsCategory.onNewEvent', {
interpolate: { event: nodeTypeDescription.displayName.replace('Trigger', '').trimEnd() },
}),
description: '',
},
];
}
const filteredOutItems = (matchedProperty.options as INodePropertyOptions[]).filter(
(categoryItem: INodePropertyOptions) => !['*', '', ' '].includes(categoryItem.name),
);
const customParsedItem = customNodeActionsParsers[nodeTypeDescription.name]?.(
matchedProperty,
nodeTypeDescription,
);
const items =
customParsedItem ??
filteredOutItems.map((categoryItem: INodePropertyOptions) => ({
...getNodeTypeBase(nodeTypeDescription),
actionKey: categoryItem.value as string,
displayName:
categoryItem.action ??
i18n.baseText('nodeCreator.actionsCategory.onEvent', {
interpolate: { event: startCase(categoryItem.name) },
}),
description: categoryItem.description || '',
displayOptions: matchedProperty.displayOptions,
values: {
[matchedProperty.name]:
matchedProperty.type === 'multiOptions' ? [categoryItem.value] : categoryItem.value,
},
}));
return items;
}
function resourceCategories(nodeTypeDescription: INodeTypeDescription): ActionTypeDescription[] {
const transformedNodes: ActionTypeDescription[] = [];
const matchedProperties = nodeTypeDescription.properties.filter(
(property) => property.displayName?.toLowerCase() === 'resource',
);
matchedProperties.forEach((property) => {
((property.options as INodePropertyOptions[]) || [])
.filter((option) => option.value !== CUSTOM_API_CALL_KEY)
.forEach((resourceOption, i, options) => {
const isSingleResource = options.length === 1;
// Match operations for the resource by checking if displayOptions matches or contains the resource name
const operations = nodeTypeDescription.properties.find(
(operation) =>
operation.name === 'operation' &&
(operation.displayOptions?.show?.resource?.includes(resourceOption.value) ||
isSingleResource),
);
if (!operations?.options) return;
const items = ((operations.options as INodePropertyOptions[]) || []).map(
(operationOption) => {
const displayName =
operationOption.action ?? `${resourceOption.name} ${startCase(operationOption.name)}`;
// We need to manually populate displayOptions as they are not present in the node description
// if the resource has only one option
const displayOptions = isSingleResource
? { show: { resource: [(options as INodePropertyOptions[])[0]?.value] } }
: operations?.displayOptions;
return {
...getNodeTypeBase(
nodeTypeDescription,
`${resourceOption.name} ${i18n.baseText('nodeCreator.actionsCategory.actions')}`,
),
actionKey: operationOption.value as string,
description: operationOption?.description ?? '',
displayOptions,
values: {
operation:
operations?.type === 'multiOptions'
? [operationOption.value]
: operationOption.value,
},
displayName,
group: ['trigger'],
};
},
);
transformedNodes.push(...items);
});
});
return transformedNodes;
}
export function useActionsGenerator() {
function generateNodeActions(node: INodeTypeDescription | undefined) {
if (!node) return [];
return [...triggersCategory(node), ...operationsCategory(node), ...resourceCategories(node)];
}
function filterActions(actions: ActionTypeDescription[]) {
// Do not show single action nodes
if (actions.length <= 1) return [];
return actions.filter(
(action: ActionTypeDescription, _: number, arr: ActionTypeDescription[]) => {
const isApiCall = action.actionKey === CUSTOM_API_CALL_KEY;
if (isApiCall) return false;
const isPlaceholderTriggerAction = action.actionKey === PLACEHOLDER_RECOMMENDED_ACTION_KEY;
return !isPlaceholderTriggerAction || (isPlaceholderTriggerAction && arr.length > 1);
},
);
}
function getSimplifiedNodeType(node: INodeTypeDescription): SimplifiedNodeType {
const { displayName, defaults, description, name, group, icon, iconUrl, codex } = node;
return {
displayName,
defaults,
description,
name,
group,
icon,
iconUrl,
codex,
};
}
function generateMergedNodesAndActions(nodeTypes: INodeTypeDescription[]) {
const visibleNodeTypes = deepCopy(nodeTypes);
const actions: ActionsRecord<typeof mergedNodes> = {};
const mergedNodes: SimplifiedNodeType[] = [];
visibleNodeTypes
.filter((node) => !node.group.includes('trigger'))
.forEach((app) => {
const appActions = generateNodeActions(app);
actions[app.name] = appActions;
mergedNodes.push(getSimplifiedNodeType(app));
});
visibleNodeTypes
.filter((node) => node.group.includes('trigger'))
.forEach((trigger) => {
const normalizedName = trigger.name.replace('Trigger', '');
const triggerActions = generateNodeActions(trigger);
const appActions = actions?.[normalizedName] || [];
const app = mergedNodes.find((node) => node.name === normalizedName);
if (app && appActions?.length > 0) {
// merge triggers into regular nodes that match
const mergedActions = filterActions([...appActions, ...triggerActions]);
actions[normalizedName] = mergedActions;
app.description = trigger.description; // default to trigger description
} else {
actions[trigger.name] = filterActions(triggerActions);
mergedNodes.push(getSimplifiedNodeType(trigger));
}
});
return {
actions,
mergedNodes,
};
}
return {
generateMergedNodesAndActions,
};
}

View file

@ -0,0 +1,156 @@
import { ref, set } from 'vue';
import { defineStore } from 'pinia';
export type KeyboardKey = (typeof WATCHED_KEYS)[number];
interface KeyHook {
keyboardKeys: KeyboardKey[];
condition?: (type: string, activeItemId: string) => boolean;
handler: (activeItemId: string, keyboardKey: KeyboardKey) => void;
}
export const KEYBOARD_ID_ATTR = 'data-keyboard-nav-id';
export const WATCHED_KEYS = [
'ArrowUp',
'ArrowDown',
'ArrowLeft',
'ArrowRight',
'Enter',
'Escape',
'Tab',
];
export const useKeyboardNavigation = defineStore('nodeCreatorKeyboardNavigation', () => {
const selectableItems = ref<Array<WeakRef<Element>>>([]);
const activeItemId = ref<string | null>(null);
// Array of objects that contains key code and handler
const keysHooks = ref<Record<string, KeyHook>>({});
function getItemType(element?: Element) {
return element?.getAttribute('data-keyboard-nav-type');
}
function getElementId(element?: Element) {
return element?.getAttribute(KEYBOARD_ID_ATTR) || undefined;
}
function refreshSelectableItems(): Promise<void> {
return new Promise((resolve) => {
// Wait for DOM to update
cleanupSelectableItems();
setTimeout(() => {
selectableItems.value = Array.from(
document.querySelectorAll('[data-keyboard-nav-type]'),
).map((el) => new WeakRef(el));
resolve();
}, 0);
});
}
function executeKeyHooks(keyboardKey: KeyboardKey, activeItem: Element) {
const flatHooks = Object.values(keysHooks.value);
const hooks = flatHooks.filter((hook) => hook.keyboardKeys.includes(keyboardKey));
hooks.forEach((hook) => {
if (!activeItemId.value) return;
const conditionPassed =
hook.condition === undefined ||
hook.condition(getItemType(activeItem) || '', activeItemId.value);
if (conditionPassed && activeItemId.value) {
hook.handler(activeItemId.value, keyboardKey);
}
});
}
async function onKeyDown(e: KeyboardEvent) {
const pressedKey = e.key;
if (!WATCHED_KEYS.includes(pressedKey)) return;
e.preventDefault();
e.stopPropagation();
await refreshSelectableItems();
const activeItemIndex = selectableItems.value.findIndex(
(item) => getElementId(item?.deref()) === activeItemId.value,
);
const activeItem = selectableItems.value[activeItemIndex]?.deref();
const isArrowDown = pressedKey === 'ArrowDown';
const isArrowUp = pressedKey === 'ArrowUp';
if (!activeItem) return;
if (isArrowDown) {
const nextItemIndex =
activeItemIndex < selectableItems.value.length - 1 ? activeItemIndex + 1 : 0;
setActiveItem(selectableItems.value[nextItemIndex]?.deref());
}
if (isArrowUp) {
const previousIndex =
activeItemIndex > 0 ? activeItemIndex - 1 : selectableItems.value.length - 1;
setActiveItem(selectableItems.value[previousIndex]?.deref());
}
executeKeyHooks(pressedKey, activeItem);
}
function setActiveItemId(id: string) {
activeItemId.value = id;
}
function setActiveItem(item?: Element) {
const itemId = getElementId(item);
if (!itemId) return;
setActiveItemId(itemId);
if (item?.scrollIntoView) {
item?.scrollIntoView({ block: 'center' });
}
}
async function setActiveItemIndex(index: number) {
await refreshSelectableItems();
setActiveItem(selectableItems.value[index]?.deref());
}
function attachKeydownEvent() {
document.addEventListener('keydown', onKeyDown, { capture: true });
}
function detachKeydownEvent() {
cleanupSelectableItems();
document.removeEventListener('keydown', onKeyDown, { capture: true });
}
function registerKeyHook(name: string, hook: KeyHook) {
hook.keyboardKeys.forEach((keyboardKey) => {
if (WATCHED_KEYS.includes(keyboardKey)) {
set(keysHooks.value, name, hook);
} else {
throw new Error(`Key ${keyboardKey} is not supported`);
}
});
}
function cleanupSelectableItems() {
// Cleanup to make sure DOM elements get garbage collected
selectableItems.value = [];
}
function getActiveItemIndex() {
return selectableItems.value.findIndex(
(item) => getElementId(item?.deref()) === activeItemId.value,
);
}
return {
activeItemId,
attachKeydownEvent,
refreshSelectableItems,
detachKeydownEvent,
registerKeyHook,
getActiveItemIndex,
setActiveItemId,
setActiveItemIndex,
};
});

View file

@ -0,0 +1,188 @@
import { computed, ref, set } from 'vue';
import { defineStore } from 'pinia';
import { v4 as uuid } from 'uuid';
import type { INodeCreateElement, NodeFilterType, SimplifiedNodeType } from '@/Interface';
import { DEFAULT_SUBCATEGORY, TRIGGER_NODE_CREATOR_VIEW } from '@/constants';
import { useNodeCreatorStore } from '@/stores/nodeCreator';
import { useKeyboardNavigation } from './useKeyboardNavigation';
import {
transformNodeType,
subcategorizeItems,
sortNodeCreateElements,
searchNodes,
} from '../utils';
interface ViewStack {
uuid?: string;
title?: string;
subtitle?: string;
search?: string;
subcategory?: string;
nodeIcon?: {
iconType?: string;
icon?: string;
color?: string;
};
iconUrl?: string;
rootView?: NodeFilterType;
activeIndex?: number;
transitionDirection?: 'in' | 'out';
hasSearch?: boolean;
items?: INodeCreateElement[];
baselineItems?: INodeCreateElement[];
searchItems?: SimplifiedNodeType[];
forceIncludeNodes?: string[];
mode?: 'actions' | 'nodes';
baseFilter?: (item: INodeCreateElement) => boolean;
itemsMapper?: (item: INodeCreateElement) => INodeCreateElement;
}
export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
const nodeCreatorStore = useNodeCreatorStore();
const { getActiveItemIndex } = useKeyboardNavigation();
const viewStacks = ref<ViewStack[]>([]);
const activeStackItems = computed<INodeCreateElement[]>(() => {
const stack = viewStacks.value[viewStacks.value.length - 1];
if (!stack?.baselineItems) {
return stack.items ? extendItemsWithUUID(stack.items) : [];
}
if (stack.search && searchBaseItems.value) {
const searchBase =
searchBaseItems.value.length > 0 ? searchBaseItems.value : stack.baselineItems;
return extendItemsWithUUID(searchNodes(stack.search || '', searchBase));
}
return extendItemsWithUUID(stack.baselineItems);
});
const activeViewStack = computed<ViewStack>(() => {
const stack = viewStacks.value[viewStacks.value.length - 1];
if (!stack) return {};
return {
...stack,
items: activeStackItems.value,
hasSearch: (stack.baselineItems || []).length > 8 || stack?.hasSearch,
};
});
const activeViewStackMode = computed(
() => activeViewStack.value.mode || TRIGGER_NODE_CREATOR_VIEW,
);
const searchBaseItems = computed<INodeCreateElement[]>(() => {
const stack = viewStacks.value[viewStacks.value.length - 1];
if (!stack || !stack.searchItems) return [];
return stack.searchItems.map((item) => transformNodeType(item, stack.subcategory));
});
// Generate a delta between the global search results(all nodes) and the stack search results
const globalSearchItemsDiff = computed<INodeCreateElement[]>(() => {
const stack = viewStacks.value[viewStacks.value.length - 1];
if (!stack || !stack.search) return [];
const allNodes = nodeCreatorStore.mergedNodes.map((item) => transformNodeType(item));
const globalSearchResult = extendItemsWithUUID(searchNodes(stack.search || '', allNodes));
return globalSearchResult.filter((item) => {
return !activeStackItems.value.find((activeItem) => activeItem.key === item.key);
});
});
function setStackBaselineItems() {
const stack = viewStacks.value[viewStacks.value.length - 1];
if (!stack || !activeViewStack.value.uuid) return;
const subcategorizedItems = subcategorizeItems(nodeCreatorStore.mergedNodes);
let stackItems =
stack?.items ?? subcategorizedItems[stack?.subcategory ?? DEFAULT_SUBCATEGORY] ?? [];
// Ensure that the nodes specified in `stack.forceIncludeNodes` are always included,
// regardless of whether the subcategory is matched
if ((stack.forceIncludeNodes ?? []).length > 0) {
const matchedNodes = nodeCreatorStore.mergedNodes
.filter((item) => stack.forceIncludeNodes?.includes(item.name))
.map((item) => transformNodeType(item, stack.subcategory));
stackItems.push(...matchedNodes);
}
if (stack.baseFilter) {
stackItems = stackItems.filter(stack.baseFilter);
}
if (stack.itemsMapper) {
stackItems = stackItems.map(stack.itemsMapper);
}
// Sort only if non-root view
if (!stack.items) {
sortNodeCreateElements(stackItems);
}
updateCurrentViewStack({ baselineItems: stackItems });
}
function extendItemsWithUUID(items: INodeCreateElement[]) {
return items.map((item) => ({
...item,
uuid: `${item.key}-${uuid()}`,
}));
}
function pushViewStack(stack: ViewStack) {
if (activeViewStack.value.uuid) {
updateCurrentViewStack({ activeIndex: getActiveItemIndex() });
}
const newStackUuid = uuid();
viewStacks.value.push({
...stack,
uuid: newStackUuid,
transitionDirection: 'in',
activeIndex: 0,
});
setStackBaselineItems();
}
function popViewStack() {
if (activeViewStack.value.uuid) {
viewStacks.value.pop();
updateCurrentViewStack({ transitionDirection: 'out' });
}
}
function updateCurrentViewStack(stack: Partial<ViewStack>) {
const currentStack = viewStacks.value[viewStacks.value.length - 1];
const matchedIndex = viewStacks.value.findIndex((s) => s.uuid === currentStack.uuid);
if (!currentStack) return;
// For each key in the stack, update the matched stack
Object.keys(stack).forEach((key) => {
const typedKey = key as keyof ViewStack;
set(viewStacks.value[matchedIndex], key, stack[typedKey]);
});
}
function resetViewStacks() {
viewStacks.value = [];
}
return {
viewStacks,
activeViewStack,
activeViewStackMode,
globalSearchItemsDiff,
resetViewStacks,
updateCurrentViewStack,
pushViewStack,
popViewStack,
};
});

View file

@ -1,198 +0,0 @@
import { getCurrentInstance, computed } from 'vue';
import {
CORE_NODES_CATEGORY,
WEBHOOK_NODE_TYPE,
OTHER_TRIGGER_NODES_SUBCATEGORY,
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
MANUAL_TRIGGER_NODE_TYPE,
SCHEDULE_TRIGGER_NODE_TYPE,
REGULAR_NODE_FILTER,
TRANSFORM_DATA_SUBCATEGORY,
FILES_SUBCATEGORY,
FLOWS_CONTROL_SUBCATEGORY,
HELPERS_SUBCATEGORY,
TRIGGER_NODE_FILTER,
} from '@/constants';
import { useNodeCreatorStore } from '@/stores/nodeCreator';
export default () => {
const instance = getCurrentInstance();
const nodeCreatorStore = useNodeCreatorStore();
const VIEWS = [
{
value: REGULAR_NODE_FILTER,
title: instance?.proxy.$locale.baseText('nodeCreator.triggerHelperPanel.whatHappensNext'),
items: [
{
key: '*',
type: 'subcategory',
properties: {
subcategory: 'App Regular Nodes',
icon: 'globe',
},
},
{
type: 'subcategory',
key: TRANSFORM_DATA_SUBCATEGORY,
category: CORE_NODES_CATEGORY,
properties: {
subcategory: TRANSFORM_DATA_SUBCATEGORY,
icon: 'pen',
},
},
{
type: 'subcategory',
key: HELPERS_SUBCATEGORY,
category: CORE_NODES_CATEGORY,
properties: {
subcategory: HELPERS_SUBCATEGORY,
icon: 'toolbox',
},
},
{
type: 'subcategory',
key: FLOWS_CONTROL_SUBCATEGORY,
category: CORE_NODES_CATEGORY,
properties: {
subcategory: FLOWS_CONTROL_SUBCATEGORY,
icon: 'code-branch',
},
},
{
type: 'subcategory',
key: FILES_SUBCATEGORY,
category: CORE_NODES_CATEGORY,
properties: {
subcategory: FILES_SUBCATEGORY,
icon: 'file-alt',
},
},
{
key: TRIGGER_NODE_FILTER,
type: 'view',
properties: {
title: instance?.proxy.$locale.baseText(
'nodeCreator.triggerHelperPanel.addAnotherTrigger',
),
icon: 'bolt',
withTopBorder: true,
description: instance?.proxy.$locale.baseText(
'nodeCreator.triggerHelperPanel.addAnotherTriggerDescription',
),
},
},
],
},
{
value: TRIGGER_NODE_FILTER,
title: instance?.proxy.$locale.baseText('nodeCreator.triggerHelperPanel.selectATrigger'),
description: instance?.proxy.$locale.baseText(
'nodeCreator.triggerHelperPanel.selectATriggerDescription',
),
items: [
{
key: '*',
type: 'subcategory',
properties: {
subcategory: 'App Trigger Nodes',
icon: 'satellite-dish',
},
},
{
key: SCHEDULE_TRIGGER_NODE_TYPE,
type: 'node',
category: [CORE_NODES_CATEGORY],
properties: {
nodeType: {
group: [],
name: SCHEDULE_TRIGGER_NODE_TYPE,
displayName: instance?.proxy.$locale.baseText(
'nodeCreator.triggerHelperPanel.scheduleTriggerDisplayName',
),
description: instance?.proxy.$locale.baseText(
'nodeCreator.triggerHelperPanel.scheduleTriggerDescription',
),
icon: 'fa:clock',
},
},
},
{
key: WEBHOOK_NODE_TYPE,
type: 'node',
category: [CORE_NODES_CATEGORY],
properties: {
nodeType: {
group: [],
name: WEBHOOK_NODE_TYPE,
displayName: instance?.proxy.$locale.baseText(
'nodeCreator.triggerHelperPanel.webhookTriggerDisplayName',
),
description: instance?.proxy.$locale.baseText(
'nodeCreator.triggerHelperPanel.webhookTriggerDescription',
),
iconData: {
type: 'file',
icon: 'webhook',
fileBuffer: '/static/webhook-icon.svg',
},
},
},
},
{
key: MANUAL_TRIGGER_NODE_TYPE,
type: 'node',
category: [CORE_NODES_CATEGORY],
properties: {
nodeType: {
group: [],
name: MANUAL_TRIGGER_NODE_TYPE,
displayName: instance?.proxy.$locale.baseText(
'nodeCreator.triggerHelperPanel.manualTriggerDisplayName',
),
description: instance?.proxy.$locale.baseText(
'nodeCreator.triggerHelperPanel.manualTriggerDescription',
),
icon: 'fa:mouse-pointer',
},
},
},
{
key: EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
type: 'node',
category: [CORE_NODES_CATEGORY],
properties: {
nodeType: {
group: [],
name: EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
displayName: instance?.proxy.$locale.baseText(
'nodeCreator.triggerHelperPanel.workflowTriggerDisplayName',
),
description: instance?.proxy.$locale.baseText(
'nodeCreator.triggerHelperPanel.workflowTriggerDescription',
),
icon: 'fa:sign-out-alt',
},
},
},
{
type: 'subcategory',
key: OTHER_TRIGGER_NODES_SUBCATEGORY,
category: CORE_NODES_CATEGORY,
properties: {
subcategory: OTHER_TRIGGER_NODES_SUBCATEGORY,
icon: 'folder-open',
},
},
],
},
];
const activeView = computed(() => {
return VIEWS.find((v) => v.value === nodeCreatorStore.selectedView) || VIEWS[0];
});
return {
activeView,
};
};

View file

@ -0,0 +1,73 @@
import type {
NodeCreateElement,
ActionCreateElement,
SubcategorizedNodeTypes,
SimplifiedNodeType,
INodeCreateElement,
} from '@/Interface';
import { CORE_NODES_CATEGORY, DEFAULT_SUBCATEGORY } from '@/constants';
import { v4 as uuidv4 } from 'uuid';
import { sublimeSearch } from '@/utils';
export function transformNodeType(
node: SimplifiedNodeType,
subcategory?: string,
type: 'node' | 'action' = 'node',
): NodeCreateElement | ActionCreateElement {
const createElement = {
uuid: uuidv4(),
key: node.name,
subcategory:
subcategory ?? node.codex?.subcategories?.[CORE_NODES_CATEGORY]?.[0] ?? DEFAULT_SUBCATEGORY,
properties: {
...node,
},
type,
};
return type === 'action'
? (createElement as ActionCreateElement)
: (createElement as NodeCreateElement);
}
export function subcategorizeItems(items: SimplifiedNodeType[]) {
return items.reduce((acc: SubcategorizedNodeTypes, item) => {
// Only Core Nodes subcategories are valid, others are uncategorized
const isCoreNodesCategory = item.codex?.categories?.includes(CORE_NODES_CATEGORY);
const subcategories = isCoreNodesCategory
? item?.codex?.subcategories?.[CORE_NODES_CATEGORY] ?? []
: [DEFAULT_SUBCATEGORY];
subcategories.forEach((subcategory: string) => {
if (!acc[subcategory]) {
acc[subcategory] = [];
}
acc[subcategory].push(transformNodeType(item, subcategory));
});
return acc;
}, {});
}
export function sortNodeCreateElements(nodes: INodeCreateElement[]) {
return nodes.sort((a, b) => {
if (a.type !== 'node' || b.type !== 'node') return -1;
const displayNameA = a.properties?.displayName?.toLowerCase() || a.key;
const displayNameB = b.properties?.displayName?.toLowerCase() || b.key;
return displayNameA.localeCompare(displayNameB, undefined, { sensitivity: 'base' });
});
}
export function searchNodes(searchFilter: string, items: INodeCreateElement[]) {
// In order to support the old search we need to remove the 'trigger' part
const trimmedFilter = searchFilter.toLowerCase().replace('trigger', '').trimEnd();
const result = (
sublimeSearch<INodeCreateElement>(trimmedFilter, items, [
{ key: 'properties.displayName', weight: 2 },
{ key: 'properties.codex.alias', weight: 1 },
]) || []
).map(({ item }) => item);
return result;
}

View file

@ -0,0 +1,168 @@
import {
CORE_NODES_CATEGORY,
WEBHOOK_NODE_TYPE,
OTHER_TRIGGER_NODES_SUBCATEGORY,
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
MANUAL_TRIGGER_NODE_TYPE,
SCHEDULE_TRIGGER_NODE_TYPE,
REGULAR_NODE_CREATOR_VIEW,
TRANSFORM_DATA_SUBCATEGORY,
FILES_SUBCATEGORY,
FLOWS_CONTROL_SUBCATEGORY,
HELPERS_SUBCATEGORY,
TRIGGER_NODE_CREATOR_VIEW,
EMAIL_IMAP_NODE_TYPE,
DEFAULT_SUBCATEGORY,
} from '@/constants';
export function TriggerView($locale: any) {
return {
value: TRIGGER_NODE_CREATOR_VIEW,
title: $locale.baseText('nodeCreator.triggerHelperPanel.selectATrigger'),
subtitle: $locale.baseText('nodeCreator.triggerHelperPanel.selectATriggerDescription'),
items: [
{
key: DEFAULT_SUBCATEGORY,
type: 'subcategory',
properties: {
forceIncludeNodes: [WEBHOOK_NODE_TYPE, EMAIL_IMAP_NODE_TYPE],
title: 'App Trigger Nodes',
icon: 'satellite-dish',
},
},
{
key: SCHEDULE_TRIGGER_NODE_TYPE,
type: 'node',
category: [CORE_NODES_CATEGORY],
properties: {
group: [],
name: SCHEDULE_TRIGGER_NODE_TYPE,
displayName: $locale.baseText(
'nodeCreator.triggerHelperPanel.scheduleTriggerDisplayName',
),
description: $locale.baseText(
'nodeCreator.triggerHelperPanel.scheduleTriggerDescription',
),
icon: 'fa:clock',
},
},
{
key: WEBHOOK_NODE_TYPE,
type: 'node',
category: [CORE_NODES_CATEGORY],
properties: {
group: [],
name: WEBHOOK_NODE_TYPE,
displayName: $locale.baseText('nodeCreator.triggerHelperPanel.webhookTriggerDisplayName'),
description: $locale.baseText('nodeCreator.triggerHelperPanel.webhookTriggerDescription'),
iconData: {
type: 'file',
icon: 'webhook',
fileBuffer: '/static/webhook-icon.svg',
},
},
},
{
key: MANUAL_TRIGGER_NODE_TYPE,
type: 'node',
category: [CORE_NODES_CATEGORY],
properties: {
group: [],
name: MANUAL_TRIGGER_NODE_TYPE,
displayName: $locale.baseText('nodeCreator.triggerHelperPanel.manualTriggerDisplayName'),
description: $locale.baseText('nodeCreator.triggerHelperPanel.manualTriggerDescription'),
icon: 'fa:mouse-pointer',
},
},
{
key: EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
type: 'node',
category: [CORE_NODES_CATEGORY],
properties: {
group: [],
name: EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
displayName: $locale.baseText(
'nodeCreator.triggerHelperPanel.workflowTriggerDisplayName',
),
description: $locale.baseText(
'nodeCreator.triggerHelperPanel.workflowTriggerDescription',
),
icon: 'fa:sign-out-alt',
},
},
{
type: 'subcategory',
key: OTHER_TRIGGER_NODES_SUBCATEGORY,
category: CORE_NODES_CATEGORY,
properties: {
title: OTHER_TRIGGER_NODES_SUBCATEGORY,
icon: 'folder-open',
},
},
],
};
}
export function RegularView($locale: any) {
return {
value: REGULAR_NODE_CREATOR_VIEW,
title: $locale.baseText('nodeCreator.triggerHelperPanel.whatHappensNext'),
items: [
{
key: DEFAULT_SUBCATEGORY,
type: 'subcategory',
properties: {
title: 'App Regular Nodes',
icon: 'globe',
},
},
{
type: 'subcategory',
key: TRANSFORM_DATA_SUBCATEGORY,
category: CORE_NODES_CATEGORY,
properties: {
title: TRANSFORM_DATA_SUBCATEGORY,
icon: 'pen',
},
},
{
type: 'subcategory',
key: HELPERS_SUBCATEGORY,
category: CORE_NODES_CATEGORY,
properties: {
title: HELPERS_SUBCATEGORY,
icon: 'toolbox',
},
},
{
type: 'subcategory',
key: FLOWS_CONTROL_SUBCATEGORY,
category: CORE_NODES_CATEGORY,
properties: {
title: FLOWS_CONTROL_SUBCATEGORY,
icon: 'code-branch',
},
},
{
type: 'subcategory',
key: FILES_SUBCATEGORY,
category: CORE_NODES_CATEGORY,
properties: {
title: FILES_SUBCATEGORY,
icon: 'file-alt',
},
},
{
key: TRIGGER_NODE_CREATOR_VIEW,
type: 'view',
properties: {
title: $locale.baseText('nodeCreator.triggerHelperPanel.addAnotherTrigger'),
icon: 'bolt',
description: $locale.baseText(
'nodeCreator.triggerHelperPanel.addAnotherTriggerDescription',
),
},
},
],
};
}

View file

@ -15,7 +15,7 @@ export default defineComponent({
<style lang="scss" scoped>
.slide-leave-active,
.slide-enter-active {
transition: 0.3s ease;
transition: 200ms ease;
}
.slide-leave-to,
.slide-enter {

View file

@ -165,28 +165,12 @@ export const NODE_CREATOR_OPEN_SOURCES: Record<
NODE_CONNECTION_DROP: 'node_connection_drop',
'': '',
};
export const CORE_NODES_CATEGORY = 'Core Nodes';
export const COMMUNICATION_CATEGORY = 'Communication';
export const CUSTOM_NODES_CATEGORY = 'Custom Nodes';
export const RECOMMENDED_CATEGORY = 'Recommended';
export const SUBCATEGORY_DESCRIPTIONS: {
[category: string]: { [subcategory: string]: string };
} = {
'Core Nodes': {
// this - all subkeys are set from codex
Flow: 'Branches, core triggers, merge data',
Files: 'Work with CSV, XML, text, images etc.',
'Data Transformation': 'Manipulate data fields, run code',
Helpers: 'HTTP Requests (API calls), date and time, scrape HTML',
},
};
export const REGULAR_NODE_FILTER = 'Regular';
export const TRIGGER_NODE_FILTER = 'Trigger';
export const ALL_NODE_FILTER = 'All';
export const DEFAULT_SUBCATEGORY = '*';
export const REGULAR_NODE_CREATOR_VIEW = 'Regular';
export const TRIGGER_NODE_CREATOR_VIEW = 'Trigger';
export const UNCATEGORIZED_CATEGORY = 'Miscellaneous';
export const UNCATEGORIZED_SUBCATEGORY = 'Helpers';
export const PERSONALIZED_CATEGORY = 'Suggested Nodes';
export const OTHER_TRIGGER_NODES_SUBCATEGORY = 'Other Trigger Nodes';
export const TRANSFORM_DATA_SUBCATEGORY = 'Data Transformation';
export const FILES_SUBCATEGORY = 'Files';
@ -355,11 +339,6 @@ export const HIRING_BANNER = `
Love n8n? Help us build the future of automation! https://n8n.io/careers?utm_source=n8n_user&utm_medium=console_output
`;
export const NODE_TYPE_COUNT_MAPPER = {
[REGULAR_NODE_FILTER]: ['regularCount'],
[TRIGGER_NODE_FILTER]: ['triggerCount'],
[ALL_NODE_FILTER]: ['triggerCount', 'regularCount'],
};
export const TEMPLATES_NODES_FILTER = ['n8n-nodes-base.start', 'n8n-nodes-base.respondToWebhook'];
export const enum VIEWS {
@ -538,12 +517,18 @@ export const KEEP_AUTH_IN_NDV_FOR_NODES = [HTTP_REQUEST_NODE_TYPE, WEBHOOK_NODE_
export const MAIN_AUTH_FIELD_NAME = 'authentication';
export const NODE_RESOURCE_FIELD_NAME = 'resource';
export const AUTO_INSERT_ACTION_EXPERIMENT = {
name: '003_auto_insert_action',
control: 'control',
variant: 'variant',
};
export const TEMPLATE_EXPERIMENT = {
name: '002_remove_templates',
control: 'control',
variant: 'variant',
};
export const EXPERIMENTS_TO_TRACK = [TEMPLATE_EXPERIMENT.name];
export const EXPERIMENTS_TO_TRACK = [TEMPLATE_EXPERIMENT.name, AUTO_INSERT_ACTION_EXPERIMENT.name];
export const NODE_TYPES_EXCLUDED_FROM_OUTPUT_NAME_APPEND = [FILTER_NODE_TYPE];

View file

@ -7,6 +7,7 @@ import 'vue-json-pretty/lib/styles.css';
import '@jsplumb/browser-ui/css/jsplumbtoolkit.css';
import 'n8n-design-system/css/index.scss';
import './n8n-theme.scss';
import './styles/autocomplete-theme.scss';
import '@fontsource/open-sans/latin-400.css';

View file

@ -720,29 +720,22 @@
"node.discovery.pinData.canvas": "You can pin this output instead of waiting for a test event. Open node to do so.",
"node.discovery.pinData.ndv": "You can pin this output instead of waiting for a test event.",
"nodeBase.clickToAddNodeOrDragToConnect": "Click to add node<br />or drag to connect",
"nodeCreator.actionsPlaceholderNode.scheduleTrigger": "On a Schedule",
"nodeCreator.actionsPlaceholderNode.webhook": "On a Webhook call",
"nodeCreator.actionsCategory.actions": "Actions",
"nodeCreator.actionsCategory.onNewEvent": "On new {event} event",
"nodeCreator.actionsCategory.onEvent": "On {event}",
"nodeCreator.actionsCategory.triggers": "Triggers",
"nodeCreator.actionsCategory.searchActions": "Search {nodeNameTitle} Actions...",
"nodeCreator.actionsList.apiCall": "Didn't find the right event? Make a <a data-action='addHttpNode'>custom {nodeNameTitle} API call</a>",
"nodeCreator.actionsList.apiCallNoResult": "Nothing found — try making a <a data-action='addHttpNode'>custom {nodeNameTitle} API call</a>",
"nodeCreator.categoryNames.analytics": "Analytics",
"nodeCreator.categoryNames.communication": "Communication",
"nodeCreator.categoryNames.coreNodes": "Core Nodes",
"nodeCreator.categoryNames.customNodes": "Custom Nodes",
"nodeCreator.categoryNames.dataStorage": "Data & Storage",
"nodeCreator.categoryNames.development": "Development",
"nodeCreator.categoryNames.financeAccounting": "Finance & Accounting",
"nodeCreator.categoryNames.marketingContent": "Marketing & Content",
"nodeCreator.categoryNames.miscellaneous": "Miscellaneous",
"nodeCreator.categoryNames.productivity": "Productivity",
"nodeCreator.categoryNames.sales": "Sales",
"nodeCreator.categoryNames.suggestedNodes": "Suggested Nodes ✨",
"nodeCreator.categoryNames.utility": "Utility",
"nodeCreator.mainPanel.all": "All",
"nodeCreator.mainPanel.regular": "Regular",
"nodeCreator.mainPanel.trigger": "Trigger",
"nodeCreator.actionsCategory.searchActions": "Search {node} Actions...",
"nodeCreator.actionsCategory.noMatchingActions": "No matching Actions. <i>Reset search</i>",
"nodeCreator.actionsCategory.noMatchingTriggers": "No matching Triggers. <i>Reset search</i>",
"nodeCreator.actionsList.apiCall": "Didn't find the right event? Make a <a data-action='addHttpNode'>custom {node} API call</a>",
"nodeCreator.actionsCallout.noActionItems": "We don't have <strong>{nodeName}</strong> actions yet. Have one in mind? Make a <a target=\"_blank\" href=\"https://community.n8n.io/c/feature-requests/5\"> request in our community</a>",
"nodeCreator.actionsCallout.triggersStartWorkflow": "Actions need to be triggered by another node, e.g. at regular intervals with the <strong>Schedule</strong> node. <a target=\"_blank\" href=\"https://docs.n8n.io/integrations/builtin/\"> Learn more</a>",
"nodeCreator.actionsTooltip.triggersStartWorkflow": "A trigger is a step that starts your workflow. <a target=\"_blank\" href=\"https://docs.n8n.io/integrations/builtin/\"> Learn more</a>",
"nodeCreator.actionsTooltip.actionsPerformStep": "Actions perform a step once your workflow has already started. <a target=\"_blank\" href=\"https://docs.n8n.io/integrations/builtin/\"> Learn more</a>",
"nodeCreator.actionsCallout.noTriggerItems": "No <strong>{nodeName}</strong> Triggers available. Users often combine the following Triggers with <strong>{nodeName}</strong> Actions.",
"nodeCreator.categoryNames.otherCategories": "Results in other categories",
"nodeCreator.noResults.dontWorryYouCanProbablyDoItWithThe": "Dont worry, you can probably do it with the",
"nodeCreator.noResults.httpRequest": "HTTP Request",
"nodeCreator.noResults.node": "node",
@ -750,8 +743,6 @@
"nodeCreator.noResults.requestTheNode": "Request the node",
"nodeCreator.noResults.wantUsToMakeItFaster": "Want us to make it faster?",
"nodeCreator.noResults.weDidntMakeThatYet": "We didn't make that... yet",
"nodeCreator.noResults.noMatchingActions": "No actions matching your results",
"nodeCreator.noResults.clickToSeeResults": "To see all results, <a data-action='showAllNodeCreatorNodes'>click here</a>",
"nodeCreator.noResults.webhook": "Webhook",
"nodeCreator.searchBar.searchNodes": "Search nodes...",
"nodeCreator.subcategoryDescriptions.appTriggerNodes": "Runs the flow when something happens in an app like Telegram, Notion or Airtable",
@ -768,7 +759,6 @@
"nodeCreator.subcategoryNames.flow": "Flow",
"nodeCreator.subcategoryNames.helpers": "Helpers",
"nodeCreator.subcategoryNames.otherTriggerNodes": "Other ways...",
"nodeCreator.subcategoryTitles.otherTriggerNodes": "Other Trigger Nodes",
"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?",

View file

@ -136,10 +136,6 @@ export class Telemetry {
this.track('User opened nodes panel', properties);
}
break;
case 'nodeCreateList.selectedTypeChanged':
this.userNodesPanelSession.data.filterMode = properties.new_filter as string;
this.track('User changed nodes panel filter', properties);
break;
case 'nodeCreateList.destroyed':
if (
this.userNodesPanelSession.data.nodeFilter.length > 0 &&
@ -183,10 +179,7 @@ export class Telemetry {
this.track('User added action', properties);
break;
case 'nodeCreateList.onSubcategorySelected':
const selectedProperties = (properties.selected as IDataObject).properties as IDataObject;
if (selectedProperties && selectedProperties.subcategory) {
properties.category_name = selectedProperties.subcategory;
}
properties.category_name = properties.subcategory;
properties.is_subcategory = true;
properties.nodes_panel_session_id = this.userNodesPanelSession.sessionId;
delete properties.selected;

View file

@ -1,401 +1,52 @@
import { startCase } from 'lodash-es';
import { defineStore } from 'pinia';
import { STORES, TRIGGER_NODE_CREATOR_VIEW } from '@/constants';
import type {
INodePropertyCollection,
INodePropertyOptions,
IDataObject,
INodeProperties,
INodeTypeDescription,
INodeParameters,
INodeActionTypeDescription,
} from 'n8n-workflow';
import { deepCopy } from 'n8n-workflow';
import {
STORES,
MANUAL_TRIGGER_NODE_TYPE,
TRIGGER_NODE_FILTER,
STICKY_NODE_TYPE,
NODE_CREATOR_OPEN_SOURCES,
} from '@/constants';
import { useNodeTypesStore } from '@/stores/nodeTypes';
import { useWorkflowsStore } from './workflows';
import { CUSTOM_API_CALL_KEY } from '@/constants';
import type { INodeCreatorState, INodeFilterType, IUpdateInformation } from '@/Interface';
import { i18n } from '@/plugins/i18n';
import type { Telemetry } from '@/plugins/telemetry';
import { runExternalHook } from '@/utils';
import { useWebhooksStore } from '@/stores/webhooks';
NodeFilterType,
NodeCreatorOpenSource,
SimplifiedNodeType,
ActionsRecord,
} from '@/Interface';
const PLACEHOLDER_RECOMMENDED_ACTION_KEY = 'placeholder_recommended';
import { ref } from 'vue';
const customNodeActionsParsers: {
[key: string]: (
matchedProperty: INodeProperties,
nodeTypeDescription: INodeTypeDescription,
) => INodeActionTypeDescription[] | undefined;
} = {
['n8n-nodes-base.hubspotTrigger']: (matchedProperty, nodeTypeDescription) => {
const collection = matchedProperty?.options?.[0] as INodePropertyCollection;
export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
const selectedView = ref<NodeFilterType>(TRIGGER_NODE_CREATOR_VIEW);
const mergedNodes = ref<SimplifiedNodeType[]>([]);
const actions = ref<ActionsRecord<typeof mergedNodes.value>>({});
return (collection?.values[0]?.options as INodePropertyOptions[])?.map(
(categoryItem): INodeActionTypeDescription => ({
...getNodeTypeBase(
nodeTypeDescription,
i18n.baseText('nodeCreator.actionsCategory.triggers'),
),
actionKey: categoryItem.value as string,
displayName: i18n.baseText('nodeCreator.actionsCategory.onEvent', {
interpolate: { event: startCase(categoryItem.name) },
}),
description: categoryItem.description || '',
displayOptions: matchedProperty.displayOptions,
values: { eventsUi: { eventValues: [{ name: categoryItem.value }] } },
}),
);
},
};
const showScrim = ref(false);
const openSource = ref<NodeCreatorOpenSource>('');
function filterActions(actions: INodeActionTypeDescription[]) {
// Do not show single action nodes
if (actions.length <= 1) return [];
return actions.filter(
(action: INodeActionTypeDescription, _: number, arr: INodeActionTypeDescription[]) => {
const isApiCall = action.actionKey === CUSTOM_API_CALL_KEY;
if (isApiCall) return false;
const isPlaceholderTriggerAction = action.actionKey === PLACEHOLDER_RECOMMENDED_ACTION_KEY;
return !isPlaceholderTriggerAction || (isPlaceholderTriggerAction && arr.length > 1);
},
);
}
function getNodeTypeBase(nodeTypeDescription: INodeTypeDescription, category: string) {
return {
name: nodeTypeDescription.name,
group: ['trigger'],
codex: {
categories: [category],
subcategories: {
[nodeTypeDescription.displayName]: [category],
},
},
iconUrl: nodeTypeDescription.iconUrl,
icon: nodeTypeDescription.icon,
version: [1],
defaults: {
...nodeTypeDescription.defaults,
},
inputs: [],
outputs: [],
properties: [],
};
}
function operationsCategory(
nodeTypeDescription: INodeTypeDescription,
): INodeActionTypeDescription[] {
if (!!nodeTypeDescription.properties.find((property) => property.name === 'resource')) return [];
const matchedProperty = nodeTypeDescription.properties.find(
(property) => property.name?.toLowerCase() === 'operation',
);
if (!matchedProperty || !matchedProperty.options) return [];
const filteredOutItems = (matchedProperty.options as INodePropertyOptions[]).filter(
(categoryItem: INodePropertyOptions) => !['*', '', ' '].includes(categoryItem.name),
);
const items = filteredOutItems.map((item: INodePropertyOptions) => ({
...getNodeTypeBase(nodeTypeDescription, i18n.baseText('nodeCreator.actionsCategory.actions')),
actionKey: item.value as string,
displayName: item.action ?? startCase(item.name),
description: item.description ?? '',
displayOptions: matchedProperty.displayOptions,
values: {
[matchedProperty.name]: matchedProperty.type === 'multiOptions' ? [item.value] : item.value,
},
}));
// Do not return empty category
if (items.length === 0) return [];
return items;
}
function triggersCategory(nodeTypeDescription: INodeTypeDescription): INodeActionTypeDescription[] {
const matchingKeys = ['event', 'events', 'trigger on'];
const isTrigger = nodeTypeDescription.displayName?.toLowerCase().includes('trigger');
const matchedProperty = nodeTypeDescription.properties.find((property) =>
matchingKeys.includes(property.displayName?.toLowerCase()),
);
if (!isTrigger) return [];
// Inject placeholder action if no events are available
// so user is able to add node to the canvas from the actions panel
if (!matchedProperty || !matchedProperty.options) {
return [
{
...getNodeTypeBase(
nodeTypeDescription,
i18n.baseText('nodeCreator.actionsCategory.triggers'),
),
actionKey: PLACEHOLDER_RECOMMENDED_ACTION_KEY,
displayName: i18n.baseText('nodeCreator.actionsCategory.onNewEvent', {
interpolate: { event: nodeTypeDescription.displayName.replace('Trigger', '').trimEnd() },
}),
description: '',
},
];
function setMergeNodes(nodes: SimplifiedNodeType[]) {
mergedNodes.value = nodes;
}
const filteredOutItems = (matchedProperty.options as INodePropertyOptions[]).filter(
(categoryItem: INodePropertyOptions) => !['*', '', ' '].includes(categoryItem.name),
);
function setActions(nodes: ActionsRecord<typeof mergedNodes.value>) {
actions.value = nodes;
}
const customParsedItem = customNodeActionsParsers[nodeTypeDescription.name]?.(
matchedProperty,
nodeTypeDescription,
);
function setShowScrim(isVisible: boolean) {
showScrim.value = isVisible;
}
const items =
customParsedItem ??
filteredOutItems.map((categoryItem: INodePropertyOptions) => ({
...getNodeTypeBase(
nodeTypeDescription,
i18n.baseText('nodeCreator.actionsCategory.triggers'),
),
actionKey: categoryItem.value as string,
displayName:
categoryItem.action ??
i18n.baseText('nodeCreator.actionsCategory.onEvent', {
interpolate: { event: startCase(categoryItem.name) },
}),
description: categoryItem.description || '',
displayOptions: matchedProperty.displayOptions,
values: {
[matchedProperty.name]:
matchedProperty.type === 'multiOptions' ? [categoryItem.value] : categoryItem.value,
},
}));
function setSelectedView(view: NodeFilterType) {
selectedView.value = view;
}
return items;
}
function setOpenSource(view: NodeCreatorOpenSource) {
openSource.value = view;
}
function resourceCategories(
nodeTypeDescription: INodeTypeDescription,
): INodeActionTypeDescription[] {
const transformedNodes: INodeActionTypeDescription[] = [];
const matchedProperties = nodeTypeDescription.properties.filter(
(property) => property.displayName?.toLowerCase() === 'resource',
);
matchedProperties.forEach((property) => {
((property.options as INodePropertyOptions[]) || [])
.filter((option) => option.value !== CUSTOM_API_CALL_KEY)
.forEach((resourceOption, i, options) => {
const isSingleResource = options.length === 1;
// Match operations for the resource by checking if displayOptions matches or contains the resource name
const operations = nodeTypeDescription.properties.find(
(operation) =>
operation.name === 'operation' &&
(operation.displayOptions?.show?.resource?.includes(resourceOption.value) ||
isSingleResource),
);
if (!operations?.options) return;
const items = ((operations.options as INodePropertyOptions[]) || []).map(
(operationOption) => {
const displayName =
operationOption.action ?? `${resourceOption.name} ${startCase(operationOption.name)}`;
// We need to manually populate displayOptions as they are not present in the node description
// if the resource has only one option
const displayOptions = isSingleResource
? { show: { resource: [(options as INodePropertyOptions[])[0]?.value] } }
: operations?.displayOptions;
return {
...getNodeTypeBase(nodeTypeDescription, resourceOption.name),
actionKey: operationOption.value as string,
description: operationOption?.description ?? '',
displayOptions,
values: {
operation:
operations?.type === 'multiOptions'
? [operationOption.value]
: operationOption.value,
},
displayName,
group: ['trigger'],
};
},
);
transformedNodes.push(...items);
});
});
return transformedNodes;
}
export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, {
state: (): INodeCreatorState => ({
itemsFilter: '',
showScrim: false,
selectedView: TRIGGER_NODE_FILTER,
rootViewHistory: [],
openSource: '',
}),
actions: {
setShowScrim(isVisible: boolean) {
this.showScrim = isVisible;
},
setSelectedView(selectedNodeType: INodeFilterType) {
this.selectedView = selectedNodeType;
if (!this.rootViewHistory.includes(selectedNodeType)) {
this.rootViewHistory.push(selectedNodeType);
}
},
closeCurrentView() {
this.rootViewHistory.pop();
this.selectedView = this.rootViewHistory[this.rootViewHistory.length - 1];
},
resetRootViewHistory() {
this.rootViewHistory = [];
},
setFilter(search: string) {
this.itemsFilter = search;
},
setAddedNodeActionParameters(action: IUpdateInformation, telemetry?: Telemetry, track = true) {
const { $onAction: onWorkflowStoreAction } = useWorkflowsStore();
const storeWatcher = onWorkflowStoreAction(
({ name, after, store: { setLastNodeParameters }, args }) => {
if (name !== 'addNode' || args[0].type !== action.key) return;
after(() => {
setLastNodeParameters(action);
if (track) this.trackActionSelected(action, telemetry);
storeWatcher();
});
},
);
return storeWatcher;
},
trackActionSelected(action: IUpdateInformation, telemetry?: Telemetry) {
const payload = {
node_type: action.key,
action: action.name,
resource: (action.value as INodeParameters).resource || '',
};
runExternalHook('nodeCreateList.addAction', useWebhooksStore(), payload);
telemetry?.trackNodesPanel('nodeCreateList.addAction', payload);
},
},
getters: {
visibleNodesWithActions(): INodeTypeDescription[] {
const nodes = deepCopy(useNodeTypesStore().visibleNodeTypes);
const nodesWithActions = nodes.map((node) => {
node.actions = [
...triggersCategory(node),
...operationsCategory(node),
...resourceCategories(node),
];
return node;
});
return nodesWithActions;
},
mergedAppNodes(): INodeTypeDescription[] {
const triggers = this.visibleNodesWithActions.filter((node) =>
node.group.includes('trigger'),
);
const apps = this.visibleNodesWithActions
.filter((node) => !node.group.includes('trigger'))
.map((node) => {
const newNode = deepCopy(node);
newNode.actions = newNode.actions || [];
return newNode;
});
triggers.forEach((node) => {
const normalizedName = node.name.toLowerCase().replace('trigger', '');
const app = apps.find((node) => node.name.toLowerCase() === normalizedName);
const newNode = deepCopy(node);
if (app && app.actions?.length) {
// merge triggers into regular nodes that match
app?.actions?.push(...(newNode.actions || []));
app.description = newNode.description; // default to trigger description
} else {
newNode.actions = newNode.actions || [];
apps.push(newNode);
}
});
const filteredNodes = apps.map((node) => ({
...node,
actions: filterActions(node.actions || []),
}));
return filteredNodes;
},
getNodeTypesWithManualTrigger:
() =>
(nodeType?: string): string[] => {
if (!nodeType) return [];
const { workflowTriggerNodes } = useWorkflowsStore();
const isTrigger = useNodeTypesStore().isTriggerNode(nodeType);
const workflowContainsTrigger = workflowTriggerNodes.length > 0;
const isTriggerPanel = useNodeCreatorStore().selectedView === TRIGGER_NODE_FILTER;
const isStickyNode = nodeType === STICKY_NODE_TYPE;
const singleNodeOpenSources = [
NODE_CREATOR_OPEN_SOURCES.PLUS_ENDPOINT,
NODE_CREATOR_OPEN_SOURCES.NODE_CONNECTION_ACTION,
NODE_CREATOR_OPEN_SOURCES.NODE_CONNECTION_DROP,
];
// If the node creator was opened from the plus endpoint, node connection action, or node connection drop
// then we do not want to append the manual trigger
const isSingleNodeOpenSource = singleNodeOpenSources.includes(
useNodeCreatorStore().openSource,
);
const shouldAppendManualTrigger =
!isSingleNodeOpenSource &&
!isTrigger &&
!workflowContainsTrigger &&
isTriggerPanel &&
!isStickyNode;
const nodeTypes = shouldAppendManualTrigger
? [MANUAL_TRIGGER_NODE_TYPE, nodeType]
: [nodeType];
return nodeTypes;
},
getActionData:
() =>
(actionItem: INodeActionTypeDescription): IUpdateInformation => {
const displayOptions = actionItem.displayOptions;
const displayConditions = Object.keys(displayOptions?.show || {}).reduce(
(acc: IDataObject, showCondition: string) => {
acc[showCondition] = displayOptions?.show?.[showCondition]?.[0];
return acc;
},
{},
);
return {
name: actionItem.displayName,
key: actionItem.name as string,
value: { ...actionItem.values, ...displayConditions } as INodeParameters,
};
},
},
return {
openSource,
selectedView,
showScrim,
mergedNodes,
actions,
setShowScrim,
setSelectedView,
setOpenSource,
setActions,
setMergeNodes,
};
});

View file

@ -6,14 +6,9 @@ import {
getResourceLocatorResults,
} from '@/api/nodeTypes';
import { DEFAULT_NODETYPE_VERSION, STORES } from '@/constants';
import type {
ICategoriesWithNodes,
INodeCreateElement,
INodeTypesState,
IResourceLocatorReqParams,
} from '@/Interface';
import type { INodeTypesState, IResourceLocatorReqParams } from '@/Interface';
import { addHeaders, addNodeTranslation } from '@/plugins/i18n';
import { omit, getCategoriesWithNodes, getCategorizedList } from '@/utils';
import { omit } from '@/utils';
import type {
ILoadOptions,
INodeCredentials,
@ -27,8 +22,7 @@ import { defineStore } from 'pinia';
import Vue from 'vue';
import { useCredentialsStore } from './credentials';
import { useRootStore } from './n8nRootStore';
import { useUsersStore } from './users';
import { useNodeCreatorStore } from './nodeCreator';
function getNodeVersions(nodeType: INodeTypeDescription) {
return Array.isArray(nodeType.version) ? nodeType.version : [nodeType.version];
}
@ -88,13 +82,6 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, {
visibleNodeTypes(): INodeTypeDescription[] {
return this.allLatestNodeTypes.filter((nodeType: INodeTypeDescription) => !nodeType.hidden);
},
categoriesWithNodes(): ICategoriesWithNodes {
const usersStore = useUsersStore();
return getCategoriesWithNodes(this.visibleNodeTypes, usersStore.personalizedNodeTypes);
},
categorizedItems(): INodeCreateElement[] {
return getCategorizedList(this.categoriesWithNodes);
},
},
actions: {
setNodeTypes(newNodeTypes: INodeTypeDescription[] = []): void {
@ -128,9 +115,6 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, {
{ ...this.nodeTypes },
);
Vue.set(this, 'nodeTypes', nodeTypes);
// Trigger compute of mergedAppNodes getter so it's ready when user opens the node creator
useNodeCreatorStore().mergedAppNodes;
},
removeNodeTypes(nodeTypesToRemove: INodeTypeDescription[]): void {
this.nodeTypes = nodeTypesToRemove.reduce(

View file

@ -4,24 +4,13 @@ import { useNodeTypesStore } from './../stores/nodeTypes';
import type { INodeCredentialDescription } from './../../../workflow/src/Interfaces';
import {
CORE_NODES_CATEGORY,
CUSTOM_NODES_CATEGORY,
SUBCATEGORY_DESCRIPTIONS,
UNCATEGORIZED_CATEGORY,
UNCATEGORIZED_SUBCATEGORY,
PERSONALIZED_CATEGORY,
NON_ACTIVATABLE_TRIGGER_NODE_TYPES,
TEMPLATES_NODES_FILTER,
REGULAR_NODE_FILTER,
TRIGGER_NODE_FILTER,
ALL_NODE_FILTER,
MAPPING_PARAMS,
} from '@/constants';
import type {
INodeCreateElement,
ICategoriesWithNodes,
INodeUi,
ITemplatesNode,
INodeItemProps,
NodeAuthenticationOption,
INodeUpdatePropertiesInformation,
} from '@/Interface';
@ -30,7 +19,6 @@ import type {
INodeExecutionData,
INodeProperties,
INodeTypeDescription,
INodeActionTypeDescription,
NodeParameterValueType,
INodePropertyOptions,
INodePropertyCollection,
@ -48,158 +36,6 @@ const CRED_KEYWORDS_TO_FILTER = ['API', 'OAuth1', 'OAuth2'];
const NODE_KEYWORDS_TO_FILTER = ['Trigger'];
const COMMUNITY_PACKAGE_NAME_REGEX = /(@\w+\/)?n8n-nodes-(?!base\b)\b\w+/g;
const addNodeToCategory = (
accu: ICategoriesWithNodes,
nodeType: INodeTypeDescription | INodeActionTypeDescription,
category: string,
subcategory: string,
) => {
if (!accu[category]) {
accu[category] = {};
}
if (!accu[category][subcategory]) {
accu[category][subcategory] = {
triggerCount: 0,
regularCount: 0,
nodes: [],
};
}
const isTrigger = nodeType.group.includes('trigger');
if (isTrigger) {
accu[category][subcategory].triggerCount++;
}
if (!isTrigger) {
accu[category][subcategory].regularCount++;
}
accu[category][subcategory].nodes.push({
type: nodeType.actionKey ? 'action' : 'node',
key: `${nodeType.name}`,
category,
properties: {
nodeType,
subcategory,
},
includedByTrigger: isTrigger,
includedByRegular: !isTrigger,
});
};
export const getCategoriesWithNodes = (
nodeTypes: INodeTypeDescription[],
uncategorizedSubcategory = UNCATEGORIZED_SUBCATEGORY,
): ICategoriesWithNodes => {
const sorted = [...nodeTypes].sort((a: INodeTypeDescription, b: INodeTypeDescription) =>
a.displayName > b.displayName ? 1 : -1,
);
const result = sorted.reduce((accu: ICategoriesWithNodes, nodeType: INodeTypeDescription) => {
if (!nodeType.codex || !nodeType.codex.categories) {
addNodeToCategory(accu, nodeType, UNCATEGORIZED_CATEGORY, uncategorizedSubcategory);
return accu;
}
nodeType.codex.categories.forEach((_category: string) => {
const category = _category.trim();
const subcategories = nodeType?.codex?.subcategories?.[category] ?? null;
if (subcategories === null || subcategories.length === 0) {
addNodeToCategory(accu, nodeType, category, uncategorizedSubcategory);
return;
}
subcategories.forEach((subcategory) => {
addNodeToCategory(accu, nodeType, category, subcategory);
});
});
return accu;
}, {});
return result;
};
const getCategories = (categoriesWithNodes: ICategoriesWithNodes): string[] => {
const excludeFromSort = [
CORE_NODES_CATEGORY,
CUSTOM_NODES_CATEGORY,
UNCATEGORIZED_CATEGORY,
PERSONALIZED_CATEGORY,
];
const categories = Object.keys(categoriesWithNodes);
const sorted = categories.filter((category: string) => !excludeFromSort.includes(category));
sorted.sort();
return [
CORE_NODES_CATEGORY,
CUSTOM_NODES_CATEGORY,
PERSONALIZED_CATEGORY,
...sorted,
UNCATEGORIZED_CATEGORY,
];
};
export const getCategorizedList = (
categoriesWithNodes: ICategoriesWithNodes,
categoryIsExpanded = false,
): INodeCreateElement[] => {
const categories = getCategories(categoriesWithNodes);
const result = categories.reduce((accu: INodeCreateElement[], category: string) => {
if (!categoriesWithNodes[category]) {
return accu;
}
const categoryEl: INodeCreateElement = {
type: 'category',
key: category,
properties: {
category,
name: category,
expanded: categoryIsExpanded,
},
};
const subcategories = Object.keys(categoriesWithNodes[category]);
if (subcategories.length === 1) {
const subcategory = categoriesWithNodes[category][subcategories[0]];
if (subcategory.triggerCount > 0) {
categoryEl.includedByTrigger = subcategory.triggerCount > 0;
}
if (subcategory.regularCount > 0) {
categoryEl.includedByRegular = subcategory.regularCount > 0;
}
return [...accu, categoryEl, ...subcategory.nodes];
}
subcategories.sort();
const subcategorized = subcategories.reduce(
(accu: INodeCreateElement[], subcategory: string) => {
const subcategoryEl: INodeCreateElement = {
type: 'subcategory',
key: `${category}_${subcategory}`,
properties: {
subcategory,
description: SUBCATEGORY_DESCRIPTIONS[category][subcategory],
},
includedByTrigger: categoriesWithNodes[category][subcategory].triggerCount > 0,
includedByRegular: categoriesWithNodes[category][subcategory].regularCount > 0,
};
if (subcategoryEl.includedByTrigger) {
categoryEl.includedByTrigger = true;
}
if (subcategoryEl.includedByRegular) {
categoryEl.includedByRegular = true;
}
accu.push(subcategoryEl);
return accu;
},
[],
);
return [...accu, categoryEl, ...subcategorized];
}, []);
return result;
};
export function getAppNameFromCredType(name: string) {
return name
.split(' ')
@ -271,35 +107,6 @@ export const executionDataToJson = (inputData: INodeExecutionData[]): IDataObjec
[],
);
export const matchesSelectType = (el: INodeCreateElement, selectedView: string) => {
if (selectedView === REGULAR_NODE_FILTER && el.includedByRegular) {
return true;
}
if (selectedView === TRIGGER_NODE_FILTER && el.includedByTrigger) {
return true;
}
return selectedView === ALL_NODE_FILTER;
};
const matchesAlias = (nodeType: INodeTypeDescription, filter: string): boolean => {
if (!nodeType.codex || !nodeType.codex.alias) {
return false;
}
return nodeType.codex.alias.reduce((accu: boolean, alias: string) => {
return accu || alias.toLowerCase().indexOf(filter) > -1;
}, false);
};
export const matchesNodeType = (el: INodeCreateElement, filter: string) => {
const nodeType = (el.properties as INodeItemProps).nodeType;
return (
nodeType.displayName.toLowerCase().indexOf(filter) !== -1 || matchesAlias(nodeType, filter)
);
};
export const hasOnlyListMode = (parameter: INodeProperties): boolean => {
return (
parameter.modes !== undefined &&

View file

@ -196,9 +196,9 @@ import {
STICKY_NODE_TYPE,
VIEWS,
WEBHOOK_NODE_TYPE,
TRIGGER_NODE_FILTER,
TRIGGER_NODE_CREATOR_VIEW,
EnterpriseEditionFeature,
REGULAR_NODE_FILTER,
REGULAR_NODE_CREATOR_VIEW,
MANUAL_TRIGGER_NODE_TYPE,
NODE_CREATOR_OPEN_SOURCES,
} from '@/constants';
@ -771,7 +771,7 @@ export default mixins(
},
showTriggerCreator(source: NodeCreatorOpenSource) {
if (this.createNodeActive) return;
this.nodeCreatorStore.setSelectedView(TRIGGER_NODE_FILTER);
this.nodeCreatorStore.setSelectedView(TRIGGER_NODE_CREATOR_VIEW);
this.nodeCreatorStore.setShowScrim(true);
this.onToggleNodeCreator({ source, createNodeActive: true });
},
@ -3723,15 +3723,15 @@ export default mixins(
// Default to the trigger tab in node creator if there's no trigger node yet
this.nodeCreatorStore.setSelectedView(
this.containsTrigger ? REGULAR_NODE_FILTER : TRIGGER_NODE_FILTER,
this.containsTrigger ? REGULAR_NODE_CREATOR_VIEW : TRIGGER_NODE_CREATOR_VIEW,
);
this.createNodeActive = createNodeActive;
const mode =
this.nodeCreatorStore.selectedView === TRIGGER_NODE_FILTER ? 'trigger' : 'regular';
this.nodeCreatorStore.selectedView === TRIGGER_NODE_CREATOR_VIEW ? 'trigger' : 'regular';
this.nodeCreatorStore.openSource = source || '';
if (createNodeActive === true) this.nodeCreatorStore.setOpenSource(source);
this.$externalHooks().run('nodeView.createNodeActiveChanged', {
source,
mode,

View file

@ -1393,12 +1393,6 @@ export interface IPostReceiveSort extends IPostReceiveBase {
};
}
export interface INodeActionTypeDescription extends INodeTypeDescription {
displayOptions?: IDisplayOptions;
values?: IDataObject;
actionKey: string;
}
export interface INodeTypeDescription extends INodeTypeBaseDescription {
version: number | number[];
defaults: INodeParameters;
@ -1437,7 +1431,6 @@ export interface INodeTypeDescription extends INodeTypeBaseDescription {
inactive: string;
};
};
actions?: INodeActionTypeDescription[];
__loadOptionsMethods?: string[]; // only for validation during build
}

View file

@ -872,8 +872,8 @@ importers:
specifier: ^5.15.3
version: 5.15.4
'@fortawesome/vue-fontawesome':
specifier: ^2.0.2
version: 2.0.8(@fortawesome/fontawesome-svg-core@1.2.36)(vue@2.7.14)
specifier: ^2.0.10
version: 2.0.10(@fortawesome/fontawesome-svg-core@1.2.36)(vue@2.7.14)
'@jsplumb/browser-ui':
specifier: ^5.13.2
version: 5.13.2
@ -1064,6 +1064,9 @@ importers:
'@vitest/coverage-c8':
specifier: ^0.28.5
version: 0.28.5(sass@1.55.0)(terser@5.16.1)
'@volar-plugins/eslint':
specifier: ^0.0.4
version: 0.0.4(eslint@8.39.0)
c8:
specifier: ^7.12.0
version: 7.12.0
@ -3760,8 +3763,8 @@ packages:
dependencies:
'@fortawesome/fontawesome-common-types': 0.2.36
/@fortawesome/vue-fontawesome@2.0.8(@fortawesome/fontawesome-svg-core@1.2.36)(vue@2.7.14):
resolution: {integrity: sha512-SRmP0q9Ox4zq8ydDR/hrH+23TVU1bdwYVnugLVaAIwklOHbf56gx6JUGlwES7zjuNYqzKgl8e39iYf6ph8qSQw==}
/@fortawesome/vue-fontawesome@2.0.10(@fortawesome/fontawesome-svg-core@1.2.36)(vue@2.7.14):
resolution: {integrity: sha512-OTETSXz+3ygD2OK2/vy82cmUBpuJqeOAg4gfnnv+f2Rir1tDIhQg026Q3NQxznq83ZLz8iNqGG9XJm26inpDeg==}
peerDependencies:
'@fortawesome/fontawesome-svg-core': ~1 || ~6
vue: ~2
@ -3999,13 +4002,6 @@ packages:
- supports-color
dev: true
/@jest/schemas@29.4.2:
resolution: {integrity: sha512-ZrGzGfh31NtdVH8tn0mgJw4khQuNHiKqdzJAFbCaERbyCP9tHlxWuL/mnMu8P7e/+k4puWjI1NOzi/sFsjce/g==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@sinclair/typebox': 0.25.21
dev: true
/@jest/schemas@29.4.3:
resolution: {integrity: sha512-VLYKXQmtmuEz6IxJsrZwzG9NvtkQsWNnWMsKxqWNu3+CnfzJQhp0WDDKWLVV9hLKr0l3SLLFRqcYHjhtyuDVxg==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -7386,6 +7382,15 @@ packages:
pretty-format: 27.5.1
dev: true
/@volar-plugins/eslint@0.0.4(eslint@8.39.0):
resolution: {integrity: sha512-CjvOHSPOyfMv4mgjFk0JOsnq0yWj4LaHb7n4bjNWqqhdwPWmaUEK5AbOloBRxix0736Z3OimH8GMjHNlMcbeiw==}
peerDependencies:
eslint: '*'
dependencies:
'@volar/shared': 1.0.24
eslint: 8.39.0
dev: true
/@volar/language-core@1.0.24:
resolution: {integrity: sha512-vTN+alJiWwK0Pax6POqrmevbtFW2dXhjwWiW/MW4f48eDYPLdyURWcr8TixO7EN/nHsUBj2udT7igFKPtjyAKg==}
dependencies:
@ -7393,6 +7398,13 @@ packages:
muggle-string: 0.1.0
dev: true
/@volar/shared@1.0.24:
resolution: {integrity: sha512-30mqmNsw49xlGhziL59z6kP6/TlBatkeOzMImUSWmn1QtqV7r2onDGgNNdCqSa1esTo4UtGup6yqqM2oUwrMSQ==}
dependencies:
typesafe-path: 0.2.2
vscode-uri: 3.0.7
dev: true
/@volar/source-map@1.0.24:
resolution: {integrity: sha512-Qsv/tkplx18pgBr8lKAbM1vcDqgkGKQzbChg6NW+v0CZc3G7FLmK+WrqEPzKlN7Cwdc6XVL559Nod8WKAfKr4A==}
dependencies:
@ -17760,7 +17772,7 @@ packages:
resolution: {integrity: sha512-qKlHR8yFVCbcEWba0H0TOC8dnLlO4vPlyEjRPw31FZ2Rupy9nLa8ZLbYny8gWEl8CkEhJqAE6IzdNELTBVcBEg==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@jest/schemas': 29.4.2
'@jest/schemas': 29.4.3
ansi-styles: 5.2.0
react-is: 18.2.0
dev: true
@ -20906,6 +20918,10 @@ packages:
- supports-color
dev: false
/typesafe-path@0.2.2:
resolution: {integrity: sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA==}
dev: true
/typescript@5.0.3:
resolution: {integrity: sha512-xv8mOEDnigb/tN9PSMTwSEqAnUvkoXMQlicOb0IUVDBSQCgBSaAAROUZYy2IcUy5qU6XajK5jjjO7TMWqBTKZA==}
engines: {node: '>=12.20'}
@ -21585,6 +21601,10 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
/vscode-uri@3.0.7:
resolution: {integrity: sha512-eOpPHogvorZRobNqJGhapa0JdwaxpjVvyBp0QIUMRMSf8ZAlqOdEquKuRmw9Qwu0qXtJIWqFtMkmvJjUZmMjVA==}
dev: true
/vue-agile@2.0.0:
resolution: {integrity: sha512-5xkSLJQNRdQ7qpEnXj5FgLg33XKRHaTZKGP5qkvteOc/uGJX89MYCjPSgdNqJ1GYFGfdGAp0jvhihW8OMuXS3g==}
dependencies: