diff --git a/packages/cli/src/LoadNodesAndCredentials.ts b/packages/cli/src/LoadNodesAndCredentials.ts index 2b1263e6d0..777fa0bb00 100644 --- a/packages/cli/src/LoadNodesAndCredentials.ts +++ b/packages/cli/src/LoadNodesAndCredentials.ts @@ -3,6 +3,7 @@ import { UserSettings, } from 'n8n-core'; import { + CodexData, ICredentialType, ILogger, INodeType, @@ -25,6 +26,8 @@ import { import * as glob from 'glob-promise'; import * as path from 'path'; +const CUSTOM_NODES_CATEGORY = 'Custom Nodes'; + class LoadNodesAndCredentialsClass { nodeTypes: INodeTypeData = {}; @@ -133,7 +136,6 @@ class LoadNodesAndCredentialsClass { * @param {string} credentialName The name of the credentials * @param {string} filePath The file to read credentials from * @returns {Promise} - * @memberof N8nPackagesInformationClass */ async loadCredentialsFromFile(credentialName: string, filePath: string): Promise { const tempModule = require(filePath); @@ -160,7 +162,6 @@ class LoadNodesAndCredentialsClass { * @param {string} nodeName Tha name of the node * @param {string} filePath The file to read node from * @returns {Promise} - * @memberof N8nPackagesInformationClass */ async loadNodeFromFile(packageName: string, nodeName: string, filePath: string): Promise { let tempNode: INodeType; @@ -169,6 +170,7 @@ class LoadNodesAndCredentialsClass { const tempModule = require(filePath); try { tempNode = new tempModule[nodeName]() as INodeType; + this.addCodex({ node: tempNode, filePath, isCustom: packageName === 'CUSTOM' }); } catch (error) { console.error(`Error loading node "${nodeName}" from: "${filePath}"`); throw error; @@ -202,6 +204,57 @@ class LoadNodesAndCredentialsClass { }; } + /** + * Retrieves `categories`, `subcategories` and alias (if defined) + * from the codex data for the node at the given file path. + * + * @param {string} filePath The file path to a `*.node.js` file + * @returns {CodexData} + */ + getCodex(filePath: string): CodexData { + const { categories, subcategories, alias } = require(`${filePath}on`); // .js to .json + return { + ...(categories && { categories }), + ...(subcategories && { subcategories }), + ...(alias && { alias }), + }; + } + + /** + * Adds a node codex `categories` and `subcategories` (if defined) + * to a node description `codex` property. + * + * @param {object} obj + * @param obj.node Node to add categories to + * @param obj.filePath Path to the built node + * @param obj.isCustom Whether the node is custom + * @returns {void} + */ + addCodex({ node, filePath, isCustom }: { + node: INodeType; + filePath: string; + isCustom: boolean; + }) { + try { + const codex = this.getCodex(filePath); + + if (isCustom) { + codex.categories = codex.categories + ? codex.categories.concat(CUSTOM_NODES_CATEGORY) + : [CUSTOM_NODES_CATEGORY]; + } + + node.description.codex = codex; + } catch (_) { + this.logger.debug(`No codex available for: ${filePath.split('/').pop()}`); + + if (isCustom) { + node.description.codex = { + categories: [CUSTOM_NODES_CATEGORY], + }; + } + } + } /** * Loads nodes and credentials from the given directory @@ -209,7 +262,6 @@ class LoadNodesAndCredentialsClass { * @param {string} setPackageName The package name to set for the found nodes * @param {string} directory The directory to look in * @returns {Promise} - * @memberof N8nPackagesInformationClass */ async loadDataFromDirectory(setPackageName: string, directory: string): Promise { const files = await glob(path.join(directory, '**/*\.@(node|credentials)\.js')); @@ -237,7 +289,6 @@ class LoadNodesAndCredentialsClass { * * @param {string} packageName The name to read data from * @returns {Promise} - * @memberof N8nPackagesInformationClass */ async loadDataFromPackage(packageName: string): Promise { // Get the absolute path of the package diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index f19c88289e..0a31144999 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -947,6 +947,9 @@ class App { const filepath = nodeType.description.icon.substr(5); + const maxAge = 7 * 24 * 60 * 60 * 1000; // 7 days + res.setHeader('Cache-control', `private max-age=${maxAge}`); + res.sendFile(filepath); }); diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 3dab3901ee..e01b747fbf 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -489,6 +489,39 @@ export interface ILinkMenuItemProperties { newWindow?: boolean; } +export interface ISubcategoryItemProps { + subcategory: string; + description: string; +} + +export interface INodeItemProps { + subcategory: string; + nodeType: INodeTypeDescription; +} + +export interface ICategoryItemProps { + expanded: boolean; +} + +export interface INodeCreateElement { + type: 'node' | 'category' | 'subcategory'; + category: string; + key: string; + includedByTrigger?: boolean; + includedByRegular?: boolean; + properties: ISubcategoryItemProps | INodeItemProps | ICategoryItemProps; +} + +export interface ICategoriesWithNodes { + [category: string]: { + [subcategory: string]: { + regularCount: number; + triggerCount: number; + nodes: INodeCreateElement[]; + }; + }; +} + export interface ITag { id: string; name: string; diff --git a/packages/editor-ui/src/components/Node.vue b/packages/editor-ui/src/components/Node.vue index 335c19e6f4..4ce5b36cef 100644 --- a/packages/editor-ui/src/components/Node.vue +++ b/packages/editor-ui/src/components/Node.vue @@ -30,7 +30,7 @@ - +
diff --git a/packages/editor-ui/src/components/NodeCreateItem.vue b/packages/editor-ui/src/components/NodeCreateItem.vue deleted file mode 100644 index a1ebeca064..0000000000 --- a/packages/editor-ui/src/components/NodeCreateItem.vue +++ /dev/null @@ -1,85 +0,0 @@ - - - - - diff --git a/packages/editor-ui/src/components/NodeCreateList.vue b/packages/editor-ui/src/components/NodeCreateList.vue deleted file mode 100644 index a00b3ff2d9..0000000000 --- a/packages/editor-ui/src/components/NodeCreateList.vue +++ /dev/null @@ -1,172 +0,0 @@ - - - - - diff --git a/packages/editor-ui/src/components/NodeCreator.vue b/packages/editor-ui/src/components/NodeCreator.vue deleted file mode 100644 index 7090d5749c..0000000000 --- a/packages/editor-ui/src/components/NodeCreator.vue +++ /dev/null @@ -1,104 +0,0 @@ - - - - - diff --git a/packages/editor-ui/src/components/NodeCreator/CategoryItem.vue b/packages/editor-ui/src/components/NodeCreator/CategoryItem.vue new file mode 100644 index 0000000000..3f4dff39ba --- /dev/null +++ b/packages/editor-ui/src/components/NodeCreator/CategoryItem.vue @@ -0,0 +1,42 @@ + + + + + + \ No newline at end of file diff --git a/packages/editor-ui/src/components/NodeCreator/CreatorItem.vue b/packages/editor-ui/src/components/NodeCreator/CreatorItem.vue new file mode 100644 index 0000000000..a024c2ec4d --- /dev/null +++ b/packages/editor-ui/src/components/NodeCreator/CreatorItem.vue @@ -0,0 +1,57 @@ + + + + + \ No newline at end of file diff --git a/packages/editor-ui/src/components/NodeCreator/ItemIterator.vue b/packages/editor-ui/src/components/NodeCreator/ItemIterator.vue new file mode 100644 index 0000000000..7cf00057ef --- /dev/null +++ b/packages/editor-ui/src/components/NodeCreator/ItemIterator.vue @@ -0,0 +1,94 @@ + + + + + + diff --git a/packages/editor-ui/src/components/NodeCreator/MainPanel.vue b/packages/editor-ui/src/components/NodeCreator/MainPanel.vue new file mode 100644 index 0000000000..bd7f665dbd --- /dev/null +++ b/packages/editor-ui/src/components/NodeCreator/MainPanel.vue @@ -0,0 +1,332 @@ + + + + + diff --git a/packages/editor-ui/src/components/NodeCreator/NoResults.vue b/packages/editor-ui/src/components/NodeCreator/NoResults.vue new file mode 100644 index 0000000000..c03823bfb2 --- /dev/null +++ b/packages/editor-ui/src/components/NodeCreator/NoResults.vue @@ -0,0 +1,126 @@ + + + + + + diff --git a/packages/editor-ui/src/components/NodeCreator/NoResultsIcon.vue b/packages/editor-ui/src/components/NodeCreator/NoResultsIcon.vue new file mode 100644 index 0000000000..697b1a42c7 --- /dev/null +++ b/packages/editor-ui/src/components/NodeCreator/NoResultsIcon.vue @@ -0,0 +1,22 @@ + \ No newline at end of file diff --git a/packages/editor-ui/src/components/NodeCreator/NodeCreator.vue b/packages/editor-ui/src/components/NodeCreator/NodeCreator.vue new file mode 100644 index 0000000000..4c9eccf5e0 --- /dev/null +++ b/packages/editor-ui/src/components/NodeCreator/NodeCreator.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/packages/editor-ui/src/components/NodeCreator/NodeItem.vue b/packages/editor-ui/src/components/NodeCreator/NodeItem.vue new file mode 100644 index 0000000000..06f4d4f4be --- /dev/null +++ b/packages/editor-ui/src/components/NodeCreator/NodeItem.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/packages/editor-ui/src/components/NodeCreator/SearchBar.vue b/packages/editor-ui/src/components/NodeCreator/SearchBar.vue new file mode 100644 index 0000000000..482650f31a --- /dev/null +++ b/packages/editor-ui/src/components/NodeCreator/SearchBar.vue @@ -0,0 +1,124 @@ + + + + + \ No newline at end of file diff --git a/packages/editor-ui/src/components/NodeCreator/SubcategoryItem.vue b/packages/editor-ui/src/components/NodeCreator/SubcategoryItem.vue new file mode 100644 index 0000000000..2ca06e3a21 --- /dev/null +++ b/packages/editor-ui/src/components/NodeCreator/SubcategoryItem.vue @@ -0,0 +1,58 @@ + + + + + + \ No newline at end of file diff --git a/packages/editor-ui/src/components/NodeCreator/SubcategoryPanel.vue b/packages/editor-ui/src/components/NodeCreator/SubcategoryPanel.vue new file mode 100644 index 0000000000..01af81b4a1 --- /dev/null +++ b/packages/editor-ui/src/components/NodeCreator/SubcategoryPanel.vue @@ -0,0 +1,96 @@ + + + + + \ No newline at end of file diff --git a/packages/editor-ui/src/components/NodeCreator/helpers.ts b/packages/editor-ui/src/components/NodeCreator/helpers.ts new file mode 100644 index 0000000000..ae194443b6 --- /dev/null +++ b/packages/editor-ui/src/components/NodeCreator/helpers.ts @@ -0,0 +1,176 @@ +import { CORE_NODES_CATEGORY, CUSTOM_NODES_CATEGORY, SUBCATEGORY_DESCRIPTIONS, UNCATEGORIZED_CATEGORY, UNCATEGORIZED_SUBCATEGORY, REGULAR_NODE_FILTER, TRIGGER_NODE_FILTER, ALL_NODE_FILTER } from '@/constants'; +import { INodeCreateElement, ICategoriesWithNodes, INodeItemProps } from '@/Interface'; +import { INodeTypeDescription } from 'n8n-workflow'; + + +export const getCategoriesWithNodes = (nodeTypes: INodeTypeDescription[]): ICategoriesWithNodes => { + return nodeTypes.reduce( + (accu: ICategoriesWithNodes, nodeType: INodeTypeDescription) => { + if (!nodeType.codex || !nodeType.codex.categories) { + accu[UNCATEGORIZED_CATEGORY][UNCATEGORIZED_SUBCATEGORY].nodes.push({ + type: 'node', + category: UNCATEGORIZED_CATEGORY, + key: `${UNCATEGORIZED_CATEGORY}_${nodeType.name}`, + properties: { + subcategory: UNCATEGORIZED_SUBCATEGORY, + nodeType, + }, + includedByTrigger: nodeType.group.includes('trigger'), + includedByRegular: !nodeType.group.includes('trigger'), + }); + return accu; + } + nodeType.codex.categories.forEach((_category: string) => { + const category = _category.trim(); + const subcategory = + nodeType.codex && + nodeType.codex.subcategories && + nodeType.codex.subcategories[category] + ? nodeType.codex.subcategories[category][0] + : UNCATEGORIZED_SUBCATEGORY; + 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: 'node', + key: `${category}_${nodeType.name}`, + category, + properties: { + nodeType, + subcategory, + }, + includedByTrigger: isTrigger, + includedByRegular: !isTrigger, + }); + }); + return accu; + }, + { + [UNCATEGORIZED_CATEGORY]: { + [UNCATEGORIZED_SUBCATEGORY]: { + triggerCount: 0, + regularCount: 0, + nodes: [], + }, + }, + }, + ); +}; + +const getCategories = (categoriesWithNodes: ICategoriesWithNodes): string[] => { + const categories = Object.keys(categoriesWithNodes); + const sorted = categories.filter( + (category: string) => + category !== CORE_NODES_CATEGORY && category !== CUSTOM_NODES_CATEGORY && category !== UNCATEGORIZED_CATEGORY, + ); + sorted.sort(); + + return [CORE_NODES_CATEGORY, CUSTOM_NODES_CATEGORY, ...sorted, UNCATEGORIZED_CATEGORY]; +}; + +export const getCategorizedList = (categoriesWithNodes: ICategoriesWithNodes): INodeCreateElement[] => { + const categories = getCategories(categoriesWithNodes); + + return categories.reduce( + (accu: INodeCreateElement[], category: string) => { + if (!categoriesWithNodes[category]) { + return accu; + } + + const categoryEl: INodeCreateElement = { + type: 'category', + key: category, + category, + properties: { + expanded: false, + }, + }; + + 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}`, + category, + 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]; + }, + [], + ); +}; + +export const matchesSelectType = (el: INodeCreateElement, selectedType: string) => { + if (selectedType === REGULAR_NODE_FILTER && el.includedByRegular) { + return true; + } + if (selectedType === TRIGGER_NODE_FILTER && el.includedByTrigger) { + return true; + } + + return selectedType === 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); +}; \ No newline at end of file diff --git a/packages/editor-ui/src/components/NodeIcon.vue b/packages/editor-ui/src/components/NodeIcon.vue index d32a1a40ef..bf13e2ef7b 100644 --- a/packages/editor-ui/src/components/NodeIcon.vue +++ b/packages/editor-ui/src/components/NodeIcon.vue @@ -1,7 +1,7 @@