diff --git a/packages/editor-ui/src/components/Node/NodeCreator/__tests__/useActionsGeneration.test.ts b/packages/editor-ui/src/components/Node/NodeCreator/__tests__/useActionsGeneration.test.ts new file mode 100644 index 0000000000000..f74f70a47fa80 --- /dev/null +++ b/packages/editor-ui/src/components/Node/NodeCreator/__tests__/useActionsGeneration.test.ts @@ -0,0 +1,390 @@ +import type { INodeProperties, INodeTypeDescription } from 'n8n-workflow'; +import { useActionsGenerator } from '../composables/useActionsGeneration'; + +describe('useActionsGenerator', () => { + const { generateMergedNodesAndActions } = useActionsGenerator(); + const NODE_NAME = 'n8n-nodes-base.test'; + const baseV2NodeWoProps: INodeTypeDescription = { + name: NODE_NAME, + displayName: 'Test', + description: 'Test Node', + defaultVersion: 2, + version: 2, + group: ['output'], + defaults: { + name: 'Test', + }, + inputs: ['main'], + outputs: ['main'], + properties: [], + }; + + describe('App actions for resource category', () => { + const resourcePropertyWithUser: INodeProperties = { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'User', + value: 'user', + }, + ], + default: 'user', + }; + const resourcePropertyWithUserAndPage: INodeProperties = { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'User', + value: 'user', + }, + { + name: 'Page', + value: 'page', + }, + ], + default: 'user', + }; + + it('returns single action for single resource & single operation without resource filter', () => { + const node: INodeTypeDescription = { + ...baseV2NodeWoProps, + properties: [ + resourcePropertyWithUser, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: {}, + options: [ + { + name: 'Get', + value: 'get', + description: 'Get description', + }, + ], + default: 'get', + }, + ], + }; + + const { actions } = generateMergedNodesAndActions([node]); + expect(actions).toEqual({ + [NODE_NAME]: [ + expect.objectContaining({ + actionKey: 'get', + description: 'Get description', + displayName: 'User Get', + codex: { + label: 'User Actions', + categories: ['Actions'], + }, + }), + ], + }); + }); + + it('returns single action for single resource & single operation with matching resource filter', () => { + const node: INodeTypeDescription = { + ...baseV2NodeWoProps, + properties: [ + resourcePropertyWithUser, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['user'], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + description: 'Get description', + }, + ], + default: 'get', + }, + ], + }; + + const { actions } = generateMergedNodesAndActions([node]); + expect(actions).toEqual({ + [NODE_NAME]: [ + expect.objectContaining({ + actionKey: 'get', + description: 'Get description', + displayName: 'User Get', + codex: { + label: 'User Actions', + categories: ['Actions'], + }, + }), + ], + }); + }); + + it('returns nothing for multiple resources & single operation without resource filter', () => { + const node: INodeTypeDescription = { + ...baseV2NodeWoProps, + properties: [ + resourcePropertyWithUserAndPage, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: {}, + options: [ + { + name: 'Get', + value: 'get', + description: 'Get description', + }, + ], + default: 'get', + }, + ], + }; + + const { actions } = generateMergedNodesAndActions([node]); + expect(actions).toEqual({ + [NODE_NAME]: [], + }); + }); + + it('returns single action for multiple resources & single operation with resource filter', () => { + const node: INodeTypeDescription = { + ...baseV2NodeWoProps, + properties: [ + resourcePropertyWithUserAndPage, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['user'], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + description: 'Get description', + }, + ], + default: 'get', + }, + ], + }; + + const { actions } = generateMergedNodesAndActions([node]); + expect(actions).toEqual({ + [NODE_NAME]: [ + expect.objectContaining({ + actionKey: 'get', + description: 'Get description', + displayName: 'User Get', + codex: { + label: 'User Actions', + categories: ['Actions'], + }, + }), + ], + }); + }); + + it('returns multiple actions for multiple resources & multiple operations with resource filters', () => { + const node: INodeTypeDescription = { + ...baseV2NodeWoProps, + properties: [ + resourcePropertyWithUserAndPage, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['user'], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + description: 'Get description', + }, + ], + default: 'get', + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['page'], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + description: 'Get description', + }, + ], + default: 'get', + }, + ], + }; + + const { actions } = generateMergedNodesAndActions([node]); + expect(actions).toEqual({ + [NODE_NAME]: [ + expect.objectContaining({ + actionKey: 'get', + description: 'Get description', + displayName: 'User Get', + codex: { + label: 'User Actions', + categories: ['Actions'], + }, + }), + expect.objectContaining({ + actionKey: 'get', + description: 'Get description', + displayName: 'Page Get', + codex: { + label: 'Page Actions', + categories: ['Actions'], + }, + }), + ], + }); + }); + + it('returns correct action for single resource & multiple operations with different versions', () => { + const node: INodeTypeDescription = { + ...baseV2NodeWoProps, + properties: [ + resourcePropertyWithUser, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + // eslint-disable-next-line @typescript-eslint/naming-convention + '@version': [1], + resource: ['user'], + }, + }, + options: [ + { + name: 'Get Version 1', + value: 'getv1', + description: 'Get version 1', + }, + ], + default: 'getv1', + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + // eslint-disable-next-line @typescript-eslint/naming-convention + '@version': [2], + resource: ['user'], + }, + }, + options: [ + { + name: 'Get Version 2', + value: 'getv2', + description: 'Get version 2', + }, + ], + default: 'getv2', + }, + ], + }; + + const { actions } = generateMergedNodesAndActions([node]); + expect(actions).toEqual({ + [NODE_NAME]: [ + expect.objectContaining({ + actionKey: 'getv2', + description: 'Get version 2', + displayName: 'User Get Version 2', + codex: { + label: 'User Actions', + categories: ['Actions'], + }, + }), + ], + }); + }); + + it('returns correct action for single resource & single operation with multiple versions', () => { + const node: INodeTypeDescription = { + ...baseV2NodeWoProps, + properties: [ + resourcePropertyWithUser, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + // eslint-disable-next-line @typescript-eslint/naming-convention + '@version': [1, 2], + resource: ['user'], + }, + }, + options: [ + { + name: 'Get Version 2', + value: 'getv2', + description: 'Get version 2', + }, + ], + default: 'getv2', + }, + ], + }; + + const { actions } = generateMergedNodesAndActions([node]); + expect(actions).toEqual({ + [NODE_NAME]: [ + expect.objectContaining({ + actionKey: 'getv2', + description: 'Get version 2', + displayName: 'User Get Version 2', + codex: { + label: 'User Actions', + categories: ['Actions'], + }, + }), + ], + }); + }); + }); +}); diff --git a/packages/editor-ui/src/components/Node/NodeCreator/composables/useActionsGeneration.ts b/packages/editor-ui/src/components/Node/NodeCreator/composables/useActionsGeneration.ts index 306c175f6ce5f..20562e9526ccc 100644 --- a/packages/editor-ui/src/components/Node/NodeCreator/composables/useActionsGeneration.ts +++ b/packages/editor-ui/src/components/Node/NodeCreator/composables/useActionsGeneration.ts @@ -15,6 +15,7 @@ const PLACEHOLDER_RECOMMENDED_ACTION_KEY = 'placeholder_recommended'; function translate(...args: Parameters) { return i18n.baseText(...args); } + // Memoize the translation function so we don't have to re-translate the same string // multiple times when generating the actions const cachedBaseText = memoize(translate, (...args) => JSON.stringify(args)); @@ -159,12 +160,26 @@ function resourceCategories(nodeTypeDescription: INodeTypeDescription): ActionTy 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), - ); + const operations = nodeTypeDescription.properties.find((operation) => { + const isOperation = operation.name === 'operation'; + const isMatchingResource = + operation.displayOptions?.show?.resource?.includes(resourceOption.value) || + isSingleResource; + + // If the operation doesn't have a version defined, it should be + // available for all versions. Otherwise, make sure the node type + // version matches the operation version + const operationVersions = operation.displayOptions?.show?.['@version']; + const nodeTypeVersions = Array.isArray(nodeTypeDescription.version) + ? nodeTypeDescription.version + : [nodeTypeDescription.version]; + + const isMatchingVersion = operationVersions + ? operationVersions.some((version) => nodeTypeVersions.includes(version)) + : true; + + return isOperation && isMatchingResource && isMatchingVersion; + }); if (!operations?.options) return; diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 8045be528eb74..0bfd7f65a1228 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -1116,6 +1116,8 @@ export interface IDisplayOptions { [key: string]: NodeParameterValue[] | undefined; }; show?: { + // eslint-disable-next-line @typescript-eslint/naming-convention + '@version'?: number[]; [key: string]: NodeParameterValue[] | undefined; };