diff --git a/cypress/e2e/4-node-creator.cy.ts b/cypress/e2e/4-node-creator.cy.ts
index e11dc9b3f2203..f3efd655b2f18 100644
--- a/cypress/e2e/4-node-creator.cy.ts
+++ b/cypress/e2e/4-node-creator.cy.ts
@@ -70,11 +70,20 @@ describe('Node Creator', () => {
.should('exist')
.should('contain.text', 'We didn\'t make that... yet');
+ nodeCreatorFeature.getters.searchBar().find('input').clear().type('edit image');
+ nodeCreatorFeature.getters.creatorItem().should('have.length', 1);
+
+ nodeCreatorFeature.getters.searchBar().find('input').clear().type('this node totally does not exist');
+ nodeCreatorFeature.getters.creatorItem().should('have.length', 0);
+
+ nodeCreatorFeature.getters.searchBar().find('input').clear()
+ nodeCreatorFeature.getters.getCreatorItem('On App Event').click();
+
nodeCreatorFeature.getters.searchBar().find('input').clear().type('edit image');
nodeCreatorFeature.getters.creatorItem().should('have.length', 0);
nodeCreatorFeature.getters.noResults()
.should('exist')
- .should('contain.text', 'To see results, click here');
+ .should('contain.text', 'To see all results, click here');
nodeCreatorFeature.getters.noResults().contains('click here').click();
nodeCreatorFeature.getters.nodeCreatorTabs().should('exist');
@@ -85,6 +94,7 @@ describe('Node Creator', () => {
})
it('should add manual trigger node', () => {
+ cy.get('.el-loading-mask').should('not.exist');
nodeCreatorFeature.getters.canvasAddButton().click();
nodeCreatorFeature.getters.getCreatorItem('Manually').click();
@@ -95,7 +105,7 @@ describe('Node Creator', () => {
nodeCreatorFeature.getters.nodeCreator().should('not.exist');
// TODO: Replace once we have canvas feature utils
- cy.get('div').contains("On clicking 'execute'").should('exist');
+ cy.get('div').contains("Add first step").should('exist');
})
it('check if non-core nodes are rendered', () => {
cy.wait('@nodesIntercept').then((interception) => {
@@ -144,7 +154,7 @@ describe('Node Creator', () => {
nodeCreatorFeature.getters.getCreatorItem(customCategory).should('exist');
nodeCreatorFeature.actions.toggleCategory(customCategory);
- nodeCreatorFeature.getters.getCreatorItem(customNode).findChildByTestId('node-item-community-tooltip').should('exist');
+ nodeCreatorFeature.getters.getCreatorItem(customNode).findChildByTestId('node-creator-item-tooltip').should('exist');
nodeCreatorFeature.getters.getCreatorItem(customNode).contains(customNodeDescription).should('exist');
nodeCreatorFeature.actions.selectNode(customNode);
diff --git a/cypress/e2e/7-workflow-actions.cy.ts b/cypress/e2e/7-workflow-actions.cy.ts
index 495614d569a9a..b7d903b9ba40e 100644
--- a/cypress/e2e/7-workflow-actions.cy.ts
+++ b/cypress/e2e/7-workflow-actions.cy.ts
@@ -66,7 +66,7 @@ describe('Workflow Actions', () => {
it('should add more tags', () => {
WorkflowPage.getters.newTagLink().click();
WorkflowPage.actions.addTags(TEST_WF_TAGS);
- WorkflowPage.getters.workflowTagElements().first().click();
+ WorkflowPage.getters.firstWorkflowTagElement().click();
WorkflowPage.actions.addTags(['Another one']);
WorkflowPage.getters.workflowTagElements().should('have.length', TEST_WF_TAGS.length + 1);
});
@@ -74,7 +74,7 @@ describe('Workflow Actions', () => {
it('should remove tags by clicking X in tag', () => {
WorkflowPage.getters.newTagLink().click();
WorkflowPage.actions.addTags(TEST_WF_TAGS);
- WorkflowPage.getters.workflowTagElements().first().click();
+ WorkflowPage.getters.firstWorkflowTagElement().click();
WorkflowPage.getters.workflowTagsContainer().find('.el-tag__close').first().click();
cy.get('body').type('{enter}');
WorkflowPage.getters.workflowTagElements().should('have.length', TEST_WF_TAGS.length - 1);
@@ -83,7 +83,7 @@ describe('Workflow Actions', () => {
it('should remove tags from dropdown', () => {
WorkflowPage.getters.newTagLink().click();
WorkflowPage.actions.addTags(TEST_WF_TAGS);
- WorkflowPage.getters.workflowTagElements().first().click();
+ WorkflowPage.getters.firstWorkflowTagElement().click();
WorkflowPage.getters.workflowTagsDropdown().find('li').first().click();
cy.get('body').type('{enter}');
WorkflowPage.getters.workflowTagElements().should('have.length', TEST_WF_TAGS.length - 1);
diff --git a/cypress/pages/features/node-creator.ts b/cypress/pages/features/node-creator.ts
index 54dc9790ba815..dad6f6ebe50d9 100644
--- a/cypress/pages/features/node-creator.ts
+++ b/cypress/pages/features/node-creator.ts
@@ -16,12 +16,13 @@ export class NodeCreator extends BasePage {
creatorItem: () => cy.getByTestId('item-iterator-item'),
communityNodeTooltip: () => cy.getByTestId('node-item-community-tooltip'),
noResults: () => cy.getByTestId('categorized-no-results'),
- nodeItemName: () => cy.getByTestId('node-item-name'),
+ nodeItemName: () => cy.getByTestId('node-creator-item-name'),
activeSubcategory: () => cy.getByTestId('categorized-items-subcategory'),
expandedCategories: () => this.getters.creatorItem().find('>div').filter('.active').invoke('text'),
};
actions = {
openNodeCreator: () => {
+ cy.get('.el-loading-mask').should('not.exist');
this.getters.plusButton().click();
this.getters.nodeCreator().should('be.visible')
},
diff --git a/cypress/pages/workflow.ts b/cypress/pages/workflow.ts
index 77001f2fc0dd2..3e0369ec7b24a 100644
--- a/cypress/pages/workflow.ts
+++ b/cypress/pages/workflow.ts
@@ -10,6 +10,7 @@ export class WorkflowPage extends BasePage {
workflowTagsContainer: () => cy.getByTestId('workflow-tags-container'),
workflowTagsInput: () => this.getters.workflowTagsContainer().then(($el) => cy.wrap($el.find('input').first())),
workflowTagElements: () => cy.get('[data-test-id="workflow-tags-container"] span.tags > span'),
+ firstWorkflowTagElement: () => cy.get('[data-test-id="workflow-tags-container"] span.tags > span:nth-child(1)'),
workflowTagsDropdown: () => cy.getByTestId('workflow-tags-dropdown'),
newTagLink: () => cy.getByTestId('new-tag-link'),
saveButton: () => cy.getByTestId('workflow-save-button'),
@@ -43,12 +44,14 @@ export class WorkflowPage extends BasePage {
addInitialNodeToCanvas: (nodeDisplayName: string) => {
this.getters.canvasPlusButton().click();
this.getters.nodeCreatorSearchBar().type(nodeDisplayName);
- this.getters.nodeCreatorSearchBar().type('{enter}{esc}');
+ this.getters.nodeCreatorSearchBar().type('{enter}');
+ cy.get('body').type('{esc}');
},
addNodeToCanvas: (nodeDisplayName: string) => {
this.getters.nodeCreatorPlusButton().click();
this.getters.nodeCreatorSearchBar().type(nodeDisplayName);
- this.getters.nodeCreatorSearchBar().type('{enter}{esc}');
+ this.getters.nodeCreatorSearchBar().type('{enter}');
+ cy.get('body').type('{esc}');
},
openNodeNdv: (nodeTypeName: string) => {
this.getters.canvasNodeByName(nodeTypeName).dblclick();
diff --git a/packages/design-system/src/components/N8nNodeCreatorNode/NodeCreatorNode.stories.ts b/packages/design-system/src/components/N8nNodeCreatorNode/NodeCreatorNode.stories.ts
new file mode 100644
index 0000000000000..7991eccca4b7d
--- /dev/null
+++ b/packages/design-system/src/components/N8nNodeCreatorNode/NodeCreatorNode.stories.ts
@@ -0,0 +1,58 @@
+/* tslint:disable:variable-name */
+import N8nNodeCreatorNode from './NodeCreatorNode.vue';
+import { StoryFn } from '@storybook/vue';
+
+export default {
+ title: 'Modules/Node Creator Node',
+ component: N8nNodeCreatorNode,
+};
+
+const DefaultTemplate: StoryFn = (args, { argTypes }) => ({
+ props: Object.keys(argTypes),
+ components: {
+ N8nNodeCreatorNode,
+ },
+ template: `
+
+
+
+
+
+ `,
+});
+
+export const WithTitle = DefaultTemplate.bind({});
+WithTitle.args = {
+ title: 'Node with title',
+ tooltipHtml: 'Bold tooltip',
+ description:
+ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean et vehicula ipsum, eu facilisis lacus. Aliquam commodo vel elit eget mollis. Quisque ac elit non purus iaculis placerat. Quisque fringilla ultrices nisi sed porta.',
+};
+
+const PanelTemplate: StoryFn = (args, { argTypes }) => ({
+ props: Object.keys(argTypes),
+ components: {
+ N8nNodeCreatorNode,
+ },
+ data() {
+ return {
+ isPanelActive: false,
+ };
+ },
+ template: `
+
+
+
+
+
+ Lorem ipsum dolor sit amet
+
+
+
+ `,
+});
+export const WithPanel = PanelTemplate.bind({});
+WithPanel.args = {
+ title: 'Node with panel',
+ isTrigger: true,
+};
diff --git a/packages/design-system/src/components/N8nNodeCreatorNode/NodeCreatorNode.vue b/packages/design-system/src/components/N8nNodeCreatorNode/NodeCreatorNode.vue
new file mode 100644
index 0000000000000..4e0aef14a932f
--- /dev/null
+++ b/packages/design-system/src/components/N8nNodeCreatorNode/NodeCreatorNode.vue
@@ -0,0 +1,125 @@
+
+
+
+
+
+
+
+
+
diff --git a/packages/design-system/src/components/N8nNodeCreatorNode/TriggerIcon.vue b/packages/design-system/src/components/N8nNodeCreatorNode/TriggerIcon.vue
new file mode 100644
index 0000000000000..dda6b227e72ec
--- /dev/null
+++ b/packages/design-system/src/components/N8nNodeCreatorNode/TriggerIcon.vue
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
diff --git a/packages/design-system/src/components/N8nNodeCreatorNode/index.ts b/packages/design-system/src/components/N8nNodeCreatorNode/index.ts
new file mode 100644
index 0000000000000..657f0d26f29e8
--- /dev/null
+++ b/packages/design-system/src/components/N8nNodeCreatorNode/index.ts
@@ -0,0 +1,3 @@
+import NodeCreatorNode from './NodeCreatorNode.vue';
+
+export default NodeCreatorNode;
diff --git a/packages/design-system/src/components/N8nNodeIcon/NodeIcon.vue b/packages/design-system/src/components/N8nNodeIcon/NodeIcon.vue
index 79234705d1381..80948018a3427 100644
--- a/packages/design-system/src/components/N8nNodeIcon/NodeIcon.vue
+++ b/packages/design-system/src/components/N8nNodeIcon/NodeIcon.vue
@@ -2,24 +2,35 @@
-
+
+
{{ nodeTypeName }}
-
+
-
+
{{ nodeTypeName ? nodeTypeName.charAt(0) : '?' }}
?
+
+
+
+
+
+
+ {{ nodeTypeName ? nodeTypeName.charAt(0) : '?' }}
+ ?
+
+
@@ -91,7 +102,7 @@ export default Vue.extend({
diff --git a/packages/editor-ui/src/components/Node/NodeCreator/CategorizedItems.vue b/packages/editor-ui/src/components/Node/NodeCreator/CategorizedItems.vue
index 9d6b50b62bfa8..ae18d680d3377 100644
--- a/packages/editor-ui/src/components/Node/NodeCreator/CategorizedItems.vue
+++ b/packages/editor-ui/src/components/Node/NodeCreator/CategorizedItems.vue
@@ -1,15 +1,14 @@
-
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
+
-
-
-
-
+
+
-
+
+
+
+
{{ $locale.baseText('nodeCreator.noResults.dontWorryYouCanProbablyDoItWithThe') }}
-
+
{{ $locale.baseText('nodeCreator.noResults.httpRequest') }}
{{ $locale.baseText('nodeCreator.noResults.or') }}
-
+
{{ $locale.baseText('nodeCreator.noResults.webhook') }}
- {{ $locale.baseText('nodeCreator.noResults.node') }}
+ {{ $locale.baseText('nodeCreator.noResults.node') }}
-
+
+
+ {{ $locale.baseText('nodeCreator.noResults.webhook') }}
+ {{ $locale.baseText('nodeCreator.noResults.node') }}
+
+
+
+
-
diff --git a/packages/editor-ui/src/components/Node/NodeCreator/ItemIterator.vue b/packages/editor-ui/src/components/Node/NodeCreator/ItemIterator.vue
index bf0396cd5a65b..06e402b5f31b1 100644
--- a/packages/editor-ui/src/components/Node/NodeCreator/ItemIterator.vue
+++ b/packages/editor-ui/src/components/Node/NodeCreator/ItemIterator.vue
@@ -1,7 +1,6 @@
-
+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
([]);
+
+function wrappedEmit(event: 'selected' | 'dragstart' | 'dragend', element: INodeCreateElement, $e?: Event) {
+ if (props.disabled) return;
+
+ emit((event as 'selected' || 'dragstart' || 'dragend'), element, $e);
+}
+function getCategoryCount(item: CategoryCreateElement) {
+ const { categoriesWithNodes } = useNodeTypesStore();
+
+ const currentCategory = categoriesWithNodes[item.category];
+ const subcategories = Object.keys(currentCategory);
+
+ // We need to sum subcategories count for the curent nodeType view
+ // to get the total count of category
+ const count = subcategories.reduce((accu: number, subcategory: string) => {
+ const countKeys = NODE_TYPE_COUNT_MAPPER[useNodeCreatorStore().selectedType];
-
diff --git a/packages/editor-ui/src/components/Node/NodeCreator/MainPanel.vue b/packages/editor-ui/src/components/Node/NodeCreator/MainPanel.vue
index 3d363927ac9cf..0c06a61cf3e9f 100644
--- a/packages/editor-ui/src/components/Node/NodeCreator/MainPanel.vue
+++ b/packages/editor-ui/src/components/Node/NodeCreator/MainPanel.vue
@@ -5,9 +5,8 @@
>
$emit('nodeTypeSelected', nodeType)"
+ v-if="nodeCreatorStore.selectedType === TRIGGER_NODE_FILTER"
+ @nodeTypeSelected="$listeners.nodeTypeSelected"
>
@@ -15,10 +14,15 @@
$emit('nodeTypeSelected', nodeType)"
+ :allItems="categorizedItems"
+ @nodeTypeSelected="$listeners.nodeTypeSelected"
+ @actionsOpen="() => {}"
>
@@ -28,73 +32,58 @@
-
+
-
-
diff --git a/packages/editor-ui/src/components/Node/NodeCreator/SearchBar.vue b/packages/editor-ui/src/components/Node/NodeCreator/SearchBar.vue
index 960077f458bab..ea899143782b0 100644
--- a/packages/editor-ui/src/components/Node/NodeCreator/SearchBar.vue
+++ b/packages/editor-ui/src/components/Node/NodeCreator/SearchBar.vue
@@ -5,12 +5,14 @@
@@ -21,50 +23,56 @@
-
@@ -74,7 +82,7 @@ export default mixins(externalHooks).extend({
height: 40px;
padding: var(--spacing-s) var(--spacing-xs);
align-items: center;
- margin: var(--spacing-s);
+ margin: var(--search-margin, var(--spacing-s));
filter: drop-shadow(0px 2px 5px rgba(46, 46, 50, 0.04));
border: 1px solid $node-creator-border-color;
diff --git a/packages/editor-ui/src/components/Node/NodeCreator/SubcategoryItem.vue b/packages/editor-ui/src/components/Node/NodeCreator/SubcategoryItem.vue
index 6467b7ac3eaef..feaaeb381b89b 100644
--- a/packages/editor-ui/src/components/Node/NodeCreator/SubcategoryItem.vue
+++ b/packages/editor-ui/src/components/Node/NodeCreator/SubcategoryItem.vue
@@ -56,6 +56,7 @@ export default Vue.extend({
.subcategory {
display: flex;
padding: 11px 16px 11px 30px;
+ user-select: none;
}
.subcategoryWithIcon {
diff --git a/packages/editor-ui/src/components/Node/NodeCreator/TriggerHelperPanel.vue b/packages/editor-ui/src/components/Node/NodeCreator/TriggerHelperPanel.vue
index 9e6b461441c83..9a80d318859e6 100644
--- a/packages/editor-ui/src/components/Node/NodeCreator/TriggerHelperPanel.vue
+++ b/packages/editor-ui/src/components/Node/NodeCreator/TriggerHelperPanel.vue
@@ -1,167 +1,364 @@
$emit('nodeTypeSelected', nodeType)"
+ :expandAllCategories="isActionsActive"
+ :subcategoryOverride="nodeAppSubcategory"
+ :alwaysShowSearch="isActionsActive"
+ :categorizedItems="computedCategorizedItems"
+ :categoriesWithNodes="computedCategoriesWithNodes"
:initialActiveIndex="0"
:searchItems="searchItems"
- :firstLevelItems="isRoot ? items : []"
- :excludedCategories="isRoot ? [] : [CORE_NODES_CATEGORY]"
- :initialActiveCategories="[COMMUNICATION_CATEGORY]"
+ :withActionsGetter="shouldShowNodeActions"
+ :firstLevelItems="firstLevelItems"
+ :showSubcategoryIcon="isActionsActive"
+ :flatten="!isActionsActive && isAppEventSubcategory"
+ :filterByType="false"
+ :lazyRender="true"
+ :allItems="allNodes"
+ :searchPlaceholder="searchPlaceholder"
+ ref="categorizedItemsRef"
+ @subcategoryClose="onSubcategoryClose"
+ @onSubcategorySelected="onSubcategorySelected"
+ @nodeTypeSelected="onNodeTypeSelected"
+ @actionsOpen="setActiveActionsNodeType"
+ @actionSelected="onActionSelected"
>
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
-
diff --git a/packages/editor-ui/src/composables/useGlobalLinkActions.ts b/packages/editor-ui/src/composables/useGlobalLinkActions.ts
new file mode 100644
index 0000000000000..5ac3abe2ed363
--- /dev/null
+++ b/packages/editor-ui/src/composables/useGlobalLinkActions.ts
@@ -0,0 +1,59 @@
+/**
+ * Creates event listeners for `data-action` attribute to allow for actions to be called from locale without using
+ * unsafe onclick attribute
+ */
+import { reactive, del, computed, onMounted, onUnmounted, getCurrentInstance } from 'vue';
+
+const state = reactive({
+ customActions: {} as Record,
+});
+
+export default () => {
+ function registerCustomAction(key: string, action: Function) {
+ state.customActions[key] = action;
+ }
+ function unregisterCustomAction(key: string) {
+ del(state.customActions, key);
+ }
+ function delegateClick(e: MouseEvent) {
+ const clickedElement = e.target;
+ if (!(clickedElement instanceof Element) || clickedElement.tagName !== 'A') return;
+
+ const actionAttribute = clickedElement.getAttribute('data-action');
+ if(actionAttribute && typeof availableActions.value[actionAttribute] === 'function') {
+ e.preventDefault();
+ availableActions.value[actionAttribute]();
+ }
+ }
+
+ function reload() {
+ if (window.top) {
+ window.top.location.reload();
+ } else {
+ window.location.reload();
+ }
+ }
+
+ const availableActions = computed<{[key: string]: Function}>(() => ({
+ reload,
+ ...state.customActions,
+ }));
+
+ onMounted(() => {
+ const instance = getCurrentInstance();
+ window.addEventListener('click', delegateClick);
+ instance?.proxy.$root.$on('registerGlobalLinkAction', registerCustomAction);
+ });
+
+ onUnmounted(() => {
+ const instance = getCurrentInstance();
+ window.removeEventListener('click', delegateClick);
+ instance?.proxy.$root.$off('registerGlobalLinkAction', registerCustomAction);
+ });
+
+ return {
+ registerCustomAction,
+ unregisterCustomAction,
+ };
+};
+
diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts
index 9f98d9d196fc0..84b69bc57d4de 100644
--- a/packages/editor-ui/src/constants.ts
+++ b/packages/editor-ui/src/constants.ts
@@ -7,6 +7,7 @@ export const PLACEHOLDER_FILLED_AT_EXECUTION_TIME = '[filled at execution time]'
// parameter input
export const CUSTOM_API_CALL_KEY = '__CUSTOM_API_CALL__';
+export const CUSTOM_API_CALL_NAME = 'Custom API Call';
// workflows
export const PLACEHOLDER_EMPTY_WORKFLOW_ID = '__EMPTY__';
@@ -79,10 +80,12 @@ export const CRON_NODE_TYPE = 'n8n-nodes-base.cron';
export const CLEARBIT_NODE_TYPE = 'n8n-nodes-base.clearbit';
export const FUNCTION_NODE_TYPE = 'n8n-nodes-base.function';
export const GITHUB_TRIGGER_NODE_TYPE = 'n8n-nodes-base.githubTrigger';
+export const GIT_NODE_TYPE = 'n8n-nodes-base.git';
export const GOOGLE_SHEETS_NODE_TYPE = 'n8n-nodes-base.googleSheets';
export const ERROR_TRIGGER_NODE_TYPE = 'n8n-nodes-base.errorTrigger';
export const ELASTIC_SECURITY_NODE_TYPE = 'n8n-nodes-base.elasticSecurity';
export const EMAIL_SEND_NODE_TYPE = 'n8n-nodes-base.emailSend';
+export const EMAIL_IMAP_NODE_TYPE = 'n8n-nodes-base.emailReadImap';
export const EXECUTE_COMMAND_NODE_TYPE = 'n8n-nodes-base.executeCommand';
export const HTTP_REQUEST_NODE_TYPE = 'n8n-nodes-base.httpRequest';
export const HUBSPOT_TRIGGER_NODE_TYPE = 'n8n-nodes-base.hubspotTrigger';
@@ -139,6 +142,7 @@ export const PIN_DATA_NODE_TYPES_DENYLIST = [
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 };
} = {
diff --git a/packages/editor-ui/src/mixins/globalLinkActions.ts b/packages/editor-ui/src/mixins/globalLinkActions.ts
deleted file mode 100644
index 9225fbeaf7e4c..0000000000000
--- a/packages/editor-ui/src/mixins/globalLinkActions.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-/**
- * Creates event listeners for `data-action` attribute to allow for actions to be called from locale without using
- * unsafe onclick attribute
- */
- import Vue from 'vue';
-
- export const globalLinkActions = Vue.extend({
- data(): {[key: string]: {[key: string]: Function}} {
- return {
- customActions: {},
- };
- },
- mounted() {
- window.addEventListener('click', this.delegateClick);
- this.$root.$on('registerGlobalLinkAction', this.registerCustomAction);
- },
- destroyed() {
- window.removeEventListener('click', this.delegateClick);
- this.$root.$off('registerGlobalLinkAction', this.registerCustomAction);
- },
- computed: {
- availableActions(): {[key: string]: Function} {
- return {
- reload: this.reload,
- ...this.customActions,
- };
- },
- },
- methods: {
- registerCustomAction(key: string, action: Function) {
- this.customActions[key] = action;
- },
- unregisterCustomAction(key: string) {
- Vue.delete(this.customActions, key);
- },
- delegateClick(e: MouseEvent) {
- const clickedElement = e.target;
- if (!(clickedElement instanceof Element) || clickedElement.tagName !== 'A') return;
-
- const actionAttribute = clickedElement.getAttribute('data-action');
- if(actionAttribute && typeof this.availableActions[actionAttribute] === 'function') {
- e.preventDefault();
- this.availableActions[actionAttribute]();
- }
- },
- reload() {
- if (window.top) {
- window.top.location.reload();
- } else {
- window.location.reload();
- }
- },
- },
- });
-
diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json
index d0ed0acbf9a7a..92044d2291820 100644
--- a/packages/editor-ui/src/plugins/i18n/locales/en.json
+++ b/packages/editor-ui/src/plugins/i18n/locales/en.json
@@ -661,6 +661,13 @@
"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
or drag to connect",
+ "nodeCreator.actionsCategory.operations": "Operations",
+ "nodeCreator.actionsCategory.onNewEvent": "On new {event} event",
+ "nodeCreator.actionsCategory.onEvent": "On {event}",
+ "nodeCreator.actionsCategory.recommended": "Recommended",
+ "nodeCreator.actionsCategory.searchActions": "Search {nodeNameTitle} Actions...",
+ "nodeCreator.actionsList.apiCall": "Didn't find the right event? Make a custom {nodeNameTitle} API call",
+ "nodeCreator.actionsList.apiCallNoResult": "Nothing found — try making a custom {nodeNameTitle} API call",
"nodeCreator.categoryNames.analytics": "Analytics",
"nodeCreator.categoryNames.communication": "Communication",
"nodeCreator.categoryNames.coreNodes": "Core Nodes",
@@ -684,7 +691,8 @@
"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.clickToSeeResults": "To see results, click here",
+ "nodeCreator.noResults.noMatchingActions": "No actions matching your results",
+ "nodeCreator.noResults.clickToSeeResults": "To see all results, click here",
"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",
diff --git a/packages/editor-ui/src/plugins/telemetry/index.ts b/packages/editor-ui/src/plugins/telemetry/index.ts
index 3ef00e6569026..59fcc126cf5ff 100644
--- a/packages/editor-ui/src/plugins/telemetry/index.ts
+++ b/packages/editor-ui/src/plugins/telemetry/index.ts
@@ -12,7 +12,6 @@ import { useSettingsStore } from "@/stores/settings";
import { useRootStore } from "@/stores/n8nRootStore";
export class Telemetry {
-
private pageEventQueue: Array<{route: Route}>;
private previousPath: string;
@@ -153,14 +152,28 @@ export class Telemetry {
break;
case 'nodeCreateList.onCategoryExpanded':
properties.is_subcategory = false;
+ properties.nodes_panel_session_id = this.userNodesPanelSession.sessionId;
this.track('User viewed node category', properties);
break;
+ case 'nodeCreateList.onViewActions':
+ properties.nodes_panel_session_id = this.userNodesPanelSession.sessionId;
+ this.track('User viewed node actions', properties);
+ break;
+ case 'nodeCreateList.onActionsCustmAPIClicked':
+ properties.nodes_panel_session_id = this.userNodesPanelSession.sessionId;
+ this.track('User clicked custom API from node actions', properties);
+ break;
+ case 'nodeCreateList.addAction':
+ properties.nodes_panel_session_id = this.userNodesPanelSession.sessionId;
+ 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.is_subcategory = true;
+ properties.nodes_panel_session_id = this.userNodesPanelSession.sessionId;
delete properties.selected;
this.track('User viewed node category', properties);
break;
diff --git a/packages/editor-ui/src/shims.d.ts b/packages/editor-ui/src/shims.d.ts
index eb1c83fcddccb..113b1ebaaac23 100644
--- a/packages/editor-ui/src/shims.d.ts
+++ b/packages/editor-ui/src/shims.d.ts
@@ -18,4 +18,11 @@ declare global {
[elem: string]: any;
}
}
+
+ interface Array {
+ findLast(
+ predicate: (value: T, index: number, obj: T[]) => unknown,
+ thisArg?: any
+ ): T
+ }
}
diff --git a/packages/editor-ui/src/stores/nodeCreator.ts b/packages/editor-ui/src/stores/nodeCreator.ts
index 3c5b4cec3858c..240eb69289b20 100644
--- a/packages/editor-ui/src/stores/nodeCreator.ts
+++ b/packages/editor-ui/src/stores/nodeCreator.ts
@@ -1,6 +1,192 @@
-import { ALL_NODE_FILTER, STORES } from "@/constants";
-import { INodeCreatorState } from "@/Interface";
+import startCase from 'lodash.startCase';
import { defineStore } from "pinia";
+import { INodePropertyCollection, INodePropertyOptions, IDataObject, INodeProperties, INodeTypeDescription, deepCopy, INodeParameters, INodeActionTypeDescription } from 'n8n-workflow';
+import { STORES, MANUAL_TRIGGER_NODE_TYPE, CORE_NODES_CATEGORY, CALENDLY_TRIGGER_NODE_TYPE, TRIGGER_NODE_FILTER } from "@/constants";
+import { useNodeTypesStore } from '@/stores/nodeTypes';
+import { useWorkflowsStore } from './workflows';
+import { CUSTOM_API_CALL_KEY, ALL_NODE_FILTER } from '@/constants';
+import { INodeCreatorState, INodeFilterType, IUpdateInformation } from '@/Interface';
+import { i18n } from '@/plugins/i18n';
+import { externalHooks } from '@/mixins/externalHooks';
+import { Telemetry } from '@/plugins/telemetry';
+
+const PLACEHOLDER_RECOMMENDED_ACTION_KEY = 'placeholder_recommended';
+
+const customNodeActionsParsers: {[key: string]: (matchedProperty: INodeProperties, nodeTypeDescription: INodeTypeDescription) => INodeActionTypeDescription[] | undefined} = {
+ ['n8n-nodes-base.hubspotTrigger']: (matchedProperty, nodeTypeDescription) => {
+ const collection = matchedProperty?.options?.[0] as INodePropertyCollection;
+
+ return (collection?.values[0]?.options as INodePropertyOptions[])?.map((categoryItem): INodeActionTypeDescription => ({
+ ...getNodeTypeBase(nodeTypeDescription, i18n.baseText('nodeCreator.actionsCategory.recommended')),
+ 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 filterSinglePlaceholderAction(actions: INodeActionTypeDescription[]) {
+ return actions.filter((action: INodeActionTypeDescription, _: number, arr: INodeActionTypeDescription[]) => {
+ 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: {},
+ 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.operations')),
+ 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 recommendedCategory(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.recommended')),
+ 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, i18n.baseText('nodeCreator.actionsCategory.recommended')),
+ actionKey: categoryItem.value as string,
+ displayName: 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): 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 => ({
@@ -9,4 +195,112 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, {
showScrim: false,
selectedType: ALL_NODE_FILTER,
}),
+ actions: {
+ setShowTabs(isVisible: boolean) {
+ this.showTabs = isVisible;
+ },
+ setShowScrim(isVisible: boolean) {
+ this.showScrim = isVisible;
+ },
+ setSelectedType(selectedNodeType: INodeFilterType) {
+ this.selectedType = selectedNodeType;
+ },
+ 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 { $externalHooks } = new externalHooks();
+
+ const payload = {
+ node_type: action.key,
+ action: action.name,
+ resource: (action.value as INodeParameters).resource || '',
+ };
+ $externalHooks().run('nodeCreateList.addAction', payload);
+ telemetry?.trackNodesPanel('nodeCreateList.addAction', payload);
+ },
+ },
+ getters: {
+ visibleNodesWithActions(): INodeTypeDescription[] {
+ const nodes = deepCopy(useNodeTypesStore().visibleNodeTypes);
+ const nodesWithActions = nodes.map((node) => {
+ const isCoreNode = node.codex?.categories?.includes(CORE_NODES_CATEGORY);
+ // Core nodes shouldn't support actions
+ node.actions = [];
+ if(isCoreNode) return node;
+
+ node.actions.push(
+ ...recommendedCategory(node),
+ ...operationsCategory(node),
+ ...resourceCategories(node),
+ );
+
+ return node;
+ });
+ return nodesWithActions;
+ },
+ mergedAppNodes(): INodeTypeDescription[] {
+ const mergedNodes = this.visibleNodesWithActions.reduce((acc: Record, node: INodeTypeDescription) => {
+
+ const clonedNode = deepCopy(node);
+ const isCoreNode = node.codex?.categories?.includes(CORE_NODES_CATEGORY);
+ const actions = node.actions || [];
+ // Do not merge core nodes
+ const normalizedName = isCoreNode ? node.name : node.name.toLowerCase().replace('trigger', '');
+ const existingNode = acc[normalizedName];
+
+ if(existingNode) existingNode.actions?.push(...actions);
+ else acc[normalizedName] = clonedNode;
+
+ if(!isCoreNode) acc[normalizedName].displayName = node.displayName.replace('Trigger', '');
+
+ acc[normalizedName].actions = filterSinglePlaceholderAction(acc[normalizedName].actions || []);
+ return acc;
+ }, {});
+ return Object.values(mergedNodes);
+ },
+ getNodeTypesWithManualTrigger: () => (nodeType?: string): string[] => {
+ if(!nodeType) return [];
+
+ const { workflowTriggerNodes } = useWorkflowsStore();
+ const isTriggerAction = nodeType.toLocaleLowerCase().includes('trigger');
+ const workflowContainsTrigger = workflowTriggerNodes.length > 0;
+ const isTriggerPanel = useNodeCreatorStore().selectedType === TRIGGER_NODE_FILTER;
+
+ const nodeTypes = !isTriggerAction && !workflowContainsTrigger && isTriggerPanel
+ ? [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,
+ };
+ },
+ },
});
diff --git a/packages/editor-ui/src/stores/nodeTypes.ts b/packages/editor-ui/src/stores/nodeTypes.ts
index fdd4a2b930c67..d536ee3c96acc 100644
--- a/packages/editor-ui/src/stores/nodeTypes.ts
+++ b/packages/editor-ui/src/stores/nodeTypes.ts
@@ -9,7 +9,7 @@ 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];
}
@@ -79,17 +79,21 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, {
}
for (const version of newNodeVersions) {
+ // Node exists with the same name
if (acc[newNodeType.name]) {
- acc[newNodeType.name][version] = newNodeType;
+ acc[newNodeType.name][version] = Object.assign(acc[newNodeType.name][version] ?? {}, newNodeType);
} else {
- acc[newNodeType.name] = { [version]: newNodeType };
+ acc[newNodeType.name] = Object.assign(acc[newNodeType.name] ?? {}, { [version]: newNodeType });
}
}
return acc;
}, { ...this.nodeTypes });
-
Vue.set(this, 'nodeTypes', nodeTypes);
+
+ // Trigger compute of mergedAppNodes getter so it's ready when user opens the node creator
+ // tslint:disable-next-line: no-unused-expression
+ useNodeCreatorStore().mergedAppNodes;
},
removeNodeTypes(nodeTypesToRemove: INodeTypeDescription[]): void {
this.nodeTypes = nodeTypesToRemove.reduce(
@@ -97,7 +101,7 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, {
this.nodeTypes,
);
},
- async getNodesInformation(nodeInfos: INodeTypeNameVersion[]): Promise {
+ async getNodesInformation(nodeInfos: INodeTypeNameVersion[], replace = true): Promise {
const rootStore = useRootStore();
const nodesInformation = await getNodesInformation(rootStore.getRestApiContext, nodeInfos);
@@ -111,7 +115,9 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, {
);
}
});
- this.setNodeTypes(nodesInformation);
+ if(replace) this.setNodeTypes(nodesInformation);
+
+ return nodesInformation;
},
async getFullNodesProperties(nodesToBeFetched: INodeTypeNameVersion[]): Promise {
const credentialsStore = useCredentialsStore();
diff --git a/packages/editor-ui/src/stores/workflows.ts b/packages/editor-ui/src/stores/workflows.ts
index 46ef5ac727948..c410d15b5b572 100644
--- a/packages/editor-ui/src/stores/workflows.ts
+++ b/packages/editor-ui/src/stores/workflows.ts
@@ -21,7 +21,7 @@ import {
IWorkflowsMap,
WorkflowsState,
} from "@/Interface";
-import {defineStore} from "pinia";
+import { defineStore } from "pinia";
import {
deepCopy,
IConnection,
@@ -40,7 +40,7 @@ import {
} from 'n8n-workflow';
import Vue from "vue";
-import {useRootStore} from "./n8nRootStore";
+import { useRootStore } from "./n8nRootStore";
import {
getActiveWorkflows,
getCurrentExecutions,
@@ -50,7 +50,7 @@ import {
} from "@/api/workflows";
import {useUIStore} from "./ui";
import {dataPinningEventBus} from "@/event-bus/data-pinning-event-bus";
-import {isJsonKeyObject, getPairedItemsMapping, stringSizeInBytes} from "@/utils";
+import {isJsonKeyObject, getPairedItemsMapping, stringSizeInBytes, isObjectLiteral} from "@/utils";
import {useNDVStore} from "./ndv";
import {useNodeTypesStore} from "./nodeTypes";
import {useWorkflowsEEStore} from "@/stores/workflows.ee";
@@ -720,7 +720,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
Vue.set(node, updateInformation.key, updateInformation.value);
},
- setNodeParameters(updateInformation: IUpdateInformation): void {
+ setNodeParameters(updateInformation: IUpdateInformation, append?: boolean): void {
// Find the node that should be updated
const node = this.workflow.nodes.find(node => {
return node.name === updateInformation.name;
@@ -732,7 +732,11 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
const uiStore = useUIStore();
uiStore.stateIsDirty = true;
- Vue.set(node, 'parameters', updateInformation.value);
+ const newParameters = !!append && isObjectLiteral(updateInformation.value)
+ ? {...node.parameters, ...updateInformation.value }
+ : updateInformation.value;
+
+ Vue.set(node, 'parameters', newParameters);
if (!this.nodeMetadata[node.name]) {
Vue.set(this.nodeMetadata, node.name, {});
@@ -740,6 +744,12 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
Vue.set(this.nodeMetadata[node.name], 'parametersLastUpdatedAt', Date.now());
},
+ setLastNodeParameters(updateInformation: IUpdateInformation) {
+ const latestNode = this.workflow.nodes.findLast((node) => node.type === updateInformation.key) as INodeUi;
+
+ if(latestNode) this.setNodeParameters({...updateInformation, name: latestNode.name}, true);
+ },
+
addNodeExecutionData(pushData: IPushDataNodeExecuteAfter): void {
if (this.workflowExecutionData === null || !this.workflowExecutionData.data) {
throw new Error('The "workflowExecutionData" is not initialized!');
diff --git a/packages/editor-ui/src/utils/nodeTypesUtils.ts b/packages/editor-ui/src/utils/nodeTypesUtils.ts
index af6afc15c12f0..80029c3d0f2d0 100644
--- a/packages/editor-ui/src/utils/nodeTypesUtils.ts
+++ b/packages/editor-ui/src/utils/nodeTypesUtils.ts
@@ -1,5 +1,6 @@
import {
CORE_NODES_CATEGORY,
+ RECOMMENDED_CATEGORY,
CUSTOM_NODES_CATEGORY,
SUBCATEGORY_DESCRIPTIONS,
UNCATEGORIZED_CATEGORY,
@@ -13,7 +14,7 @@ import {
MAPPING_PARAMS,
} from '@/constants';
import { INodeCreateElement, ICategoriesWithNodes, INodeUi, ITemplatesNode, INodeItemProps } from '@/Interface';
-import { IDataObject, INodeExecutionData, INodeProperties, INodeTypeDescription, NodeParameterValueType } from 'n8n-workflow';
+import { IDataObject, INodeExecutionData, INodeProperties, INodeTypeDescription, INodeActionTypeDescription, NodeParameterValueType } from 'n8n-workflow';
import { isResourceLocatorValue, isJsonKeyObject } from '@/utils';
/*
@@ -25,7 +26,7 @@ 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, category: string, subcategory: string) => {
+const addNodeToCategory = (accu: ICategoriesWithNodes, nodeType: INodeTypeDescription | INodeActionTypeDescription, category: string, subcategory: string) => {
if (!accu[category]) {
accu[category] = {};
}
@@ -44,7 +45,7 @@ const addNodeToCategory = (accu: ICategoriesWithNodes, nodeType: INodeTypeDescri
accu[category][subcategory].regularCount++;
}
accu[category][subcategory].nodes.push({
- type: 'node',
+ type: nodeType.actionKey ? 'action' : 'node' ,
key: `${category}_${nodeType.name}`,
category,
properties: {
@@ -56,30 +57,25 @@ const addNodeToCategory = (accu: ICategoriesWithNodes, nodeType: INodeTypeDescri
});
};
-export const getCategoriesWithNodes = (nodeTypes: INodeTypeDescription[], personalizedNodeTypes: string[]): ICategoriesWithNodes => {
+export const getCategoriesWithNodes = (nodeTypes: INodeTypeDescription[], personalizedNodeTypes: string[], uncategorizedSubcategory = UNCATEGORIZED_SUBCATEGORY): ICategoriesWithNodes => {
const sorted = [...nodeTypes].sort((a: INodeTypeDescription, b: INodeTypeDescription) => a.displayName > b.displayName? 1 : -1);
- return sorted.reduce(
+ const result = sorted.reduce(
(accu: ICategoriesWithNodes, nodeType: INodeTypeDescription) => {
if (personalizedNodeTypes.includes(nodeType.name)) {
- addNodeToCategory(accu, nodeType, PERSONALIZED_CATEGORY, UNCATEGORIZED_SUBCATEGORY);
+ addNodeToCategory(accu, nodeType, PERSONALIZED_CATEGORY, uncategorizedSubcategory);
}
if (!nodeType.codex || !nodeType.codex.categories) {
- addNodeToCategory(accu, nodeType, UNCATEGORIZED_CATEGORY, UNCATEGORIZED_SUBCATEGORY);
+ addNodeToCategory(accu, nodeType, UNCATEGORIZED_CATEGORY, uncategorizedSubcategory);
return accu;
}
nodeType.codex.categories.forEach((_category: string) => {
const category = _category.trim();
- const subcategories =
- nodeType.codex &&
- nodeType.codex.subcategories &&
- nodeType.codex.subcategories[category]
- ? nodeType.codex.subcategories[category]
- : null;
+ const subcategories = nodeType?.codex?.subcategories?.[category] ?? null;
if(subcategories === null || subcategories.length === 0) {
- addNodeToCategory(accu, nodeType, category, UNCATEGORIZED_SUBCATEGORY);
+ addNodeToCategory(accu, nodeType, category, uncategorizedSubcategory);
return;
}
@@ -92,10 +88,11 @@ export const getCategoriesWithNodes = (nodeTypes: INodeTypeDescription[], person
},
{},
);
+ return result;
};
const getCategories = (categoriesWithNodes: ICategoriesWithNodes): string[] => {
- const excludeFromSort = [CORE_NODES_CATEGORY, CUSTOM_NODES_CATEGORY, UNCATEGORIZED_CATEGORY, PERSONALIZED_CATEGORY];
+ const excludeFromSort = [CORE_NODES_CATEGORY, CUSTOM_NODES_CATEGORY, UNCATEGORIZED_CATEGORY, PERSONALIZED_CATEGORY, RECOMMENDED_CATEGORY];
const categories = Object.keys(categoriesWithNodes);
const sorted = categories.filter(
(category: string) =>
@@ -103,13 +100,13 @@ const getCategories = (categoriesWithNodes: ICategoriesWithNodes): string[] => {
);
sorted.sort();
- return [CORE_NODES_CATEGORY, CUSTOM_NODES_CATEGORY, PERSONALIZED_CATEGORY, ...sorted, UNCATEGORIZED_CATEGORY];
+ return [RECOMMENDED_CATEGORY, CORE_NODES_CATEGORY, CUSTOM_NODES_CATEGORY, PERSONALIZED_CATEGORY, ...sorted, UNCATEGORIZED_CATEGORY];
};
-export const getCategorizedList = (categoriesWithNodes: ICategoriesWithNodes): INodeCreateElement[] => {
+export const getCategorizedList = (categoriesWithNodes: ICategoriesWithNodes, categoryIsExpanded = false): INodeCreateElement[] => {
const categories = getCategories(categoriesWithNodes);
- return categories.reduce(
+ const result = categories.reduce(
(accu: INodeCreateElement[], category: string) => {
if (!categoriesWithNodes[category]) {
return accu;
@@ -120,7 +117,7 @@ export const getCategorizedList = (categoriesWithNodes: ICategoriesWithNodes): I
key: category,
category,
properties: {
- expanded: false,
+ expanded: categoryIsExpanded,
},
};
@@ -170,6 +167,7 @@ export const getCategorizedList = (categoriesWithNodes: ICategoriesWithNodes): I
},
[],
);
+ return result;
};
export function getAppNameFromCredType(name: string) {
diff --git a/packages/editor-ui/src/views/CanvasAddButton.vue b/packages/editor-ui/src/views/CanvasAddButton.vue
index 783488037232f..4f4549734ca5c 100644
--- a/packages/editor-ui/src/views/CanvasAddButton.vue
+++ b/packages/editor-ui/src/views/CanvasAddButton.vue
@@ -1,6 +1,6 @@
-
-
+
+
@@ -12,38 +12,23 @@
-