From 40741075117dd8018ca1c6d03c050c3959142ebd Mon Sep 17 00:00:00 2001 From: Tomi Turtiainen <10324676+tomi@users.noreply.github.com> Date: Tue, 28 Nov 2023 15:14:22 +0200 Subject: [PATCH] fix(editor): Fix credential icon for old node type version (#7843) If a credential was for a node's older version, its icon was not shown. --- packages/editor-ui/src/Interface.ts | 12 +- .../src/components/CredentialIcon.vue | 5 +- .../__tests__/CredentialIcon.test.ts | 73 ++ .../__tests__/testData/nodeTypesTestData.ts | 964 ++++++++++++++++++ .../editor-ui/src/stores/credentials.store.ts | 4 +- .../editor-ui/src/stores/nodeTypes.store.ts | 47 +- .../src/utils/nodeTypes/nodeTypeTransforms.ts | 57 ++ 7 files changed, 1115 insertions(+), 47 deletions(-) create mode 100644 packages/editor-ui/src/components/__tests__/CredentialIcon.test.ts create mode 100644 packages/editor-ui/src/components/__tests__/testData/nodeTypesTestData.ts create mode 100644 packages/editor-ui/src/utils/nodeTypes/nodeTypeTransforms.ts diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index bd1d6a273d..96a49f53b7 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -1310,12 +1310,14 @@ export interface ISettingsState { saveManualExecutions: boolean; } -export interface INodeTypesState { - nodeTypes: { - [nodeType: string]: { - [version: number]: INodeTypeDescription; - }; +export type NodeTypesByTypeNameAndVersion = { + [nodeType: string]: { + [version: number]: INodeTypeDescription; }; +}; + +export interface INodeTypesState { + nodeTypes: NodeTypesByTypeNameAndVersion; } export interface ITemplateState { diff --git a/packages/editor-ui/src/components/CredentialIcon.vue b/packages/editor-ui/src/components/CredentialIcon.vue index b20750e924..bbe47e104d 100644 --- a/packages/editor-ui/src/components/CredentialIcon.vue +++ b/packages/editor-ui/src/components/CredentialIcon.vue @@ -44,8 +44,11 @@ export default defineComponent({ const nodeType = this.credentialWithIcon.icon.replace('node:', ''); return this.nodeTypesStore.getNodeType(nodeType); } - const nodesWithAccess = this.credentialsStore.getNodesWithAccess(this.credentialTypeName); + if (!this.credentialTypeName) { + return null; + } + const nodesWithAccess = this.credentialsStore.getNodesWithAccess(this.credentialTypeName); if (nodesWithAccess.length) { return nodesWithAccess[0]; } diff --git a/packages/editor-ui/src/components/__tests__/CredentialIcon.test.ts b/packages/editor-ui/src/components/__tests__/CredentialIcon.test.ts new file mode 100644 index 0000000000..3ebf73f93d --- /dev/null +++ b/packages/editor-ui/src/components/__tests__/CredentialIcon.test.ts @@ -0,0 +1,73 @@ +import { createComponentRenderer } from '@/__tests__/render'; +import CredentialIcon from '@/components/CredentialIcon.vue'; +import { STORES } from '@/constants'; +import { createTestingPinia } from '@pinia/testing'; +import * as testNodeTypes from './testData/nodeTypesTestData'; +import merge from 'lodash-es/merge'; +import { groupNodeTypesByNameAndType } from '@/utils/nodeTypes/nodeTypeTransforms'; + +const defaultState = { + [STORES.CREDENTIALS]: {}, + [STORES.NODE_TYPES]: {}, +}; + +const renderComponent = createComponentRenderer(CredentialIcon, { + pinia: createTestingPinia({ + initialState: defaultState, + }), + global: { + stubs: ['n8n-tooltip'], + }, +}); + +describe('CredentialIcon', () => { + const findIcon = (baseElement: Element) => baseElement.querySelector('img'); + + it('shows correct icon for credential type that is for the latest node type version', () => { + const { baseElement } = renderComponent({ + pinia: createTestingPinia({ + initialState: merge(defaultState, { + [STORES.CREDENTIALS]: {}, + [STORES.NODE_TYPES]: { + nodeTypes: groupNodeTypesByNameAndType([ + testNodeTypes.twitterV1, + testNodeTypes.twitterV2, + ]), + }, + }), + }), + props: { + credentialTypeName: 'twitterOAuth2Api', + }, + }); + + expect(findIcon(baseElement)).toHaveAttribute( + 'src', + '/icons/n8n-nodes-base/dist/nodes/Twitter/x.svg', + ); + }); + + it('shows correct icon for credential type that is for an older node type version', () => { + const { baseElement } = renderComponent({ + pinia: createTestingPinia({ + initialState: merge(defaultState, { + [STORES.CREDENTIALS]: {}, + [STORES.NODE_TYPES]: { + nodeTypes: groupNodeTypesByNameAndType([ + testNodeTypes.twitterV1, + testNodeTypes.twitterV2, + ]), + }, + }), + }), + props: { + credentialTypeName: 'twitterOAuth1Api', + }, + }); + + expect(findIcon(baseElement)).toHaveAttribute( + 'src', + '/icons/n8n-nodes-base/dist/nodes/Twitter/x.svg', + ); + }); +}); diff --git a/packages/editor-ui/src/components/__tests__/testData/nodeTypesTestData.ts b/packages/editor-ui/src/components/__tests__/testData/nodeTypesTestData.ts new file mode 100644 index 0000000000..c5c07eb767 --- /dev/null +++ b/packages/editor-ui/src/components/__tests__/testData/nodeTypesTestData.ts @@ -0,0 +1,964 @@ +import type { INodeTypeDescription } from 'n8n-workflow'; + +export const twitterV2: INodeTypeDescription = { + displayName: 'X (Formerly Twitter)', + name: 'n8n-nodes-base.twitter', + group: ['output'], + subtitle: '={{$parameter["operation"] + ":" + $parameter["resource"]}}', + description: 'Post, like, and search tweets, send messages, search users, and add users to lists', + defaultVersion: 2, + version: 2, + defaults: { name: 'X' }, + inputs: ['main'], + outputs: ['main'], + credentials: [{ name: 'twitterOAuth2Api', required: true }], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Direct Message', + value: 'directMessage', + description: 'Send a direct message to a user', + }, + { name: 'List', value: 'list', description: 'Add a user to a list' }, + { name: 'Tweet', value: 'tweet', description: 'Create, like, search, or delete a tweet' }, + { name: 'User', value: 'user', description: 'Search users by username' }, + { name: 'Custom API Call', value: '__CUSTOM_API_CALL__' }, + ], + default: 'tweet', + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { show: { resource: ['directMessage'] } }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Send a direct message to a user', + action: 'Create Direct Message', + }, + { name: 'Custom API Call', value: '__CUSTOM_API_CALL__' }, + ], + default: 'create', + }, + { + displayName: 'User', + name: 'user', + type: 'resourceLocator', + default: { mode: 'username', value: '' }, + required: true, + description: 'The user you want to send the message to', + displayOptions: { show: { operation: ['create'], resource: ['directMessage'] } }, + modes: [ + { + displayName: 'By Username', + name: 'username', + type: 'string', + validation: [], + placeholder: 'e.g. n8n', + url: '', + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + validation: [], + placeholder: 'e.g. 1068479892537384960', + url: '', + }, + ], + }, + { + displayName: 'Text', + name: 'text', + type: 'string', + required: true, + default: '', + typeOptions: { rows: 2 }, + displayOptions: { show: { operation: ['create'], resource: ['directMessage'] } }, + description: + 'The text of the direct message. URL encoding is required. Max length of 10,000 characters.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { show: { operation: ['create'], resource: ['directMessage'] } }, + options: [ + { + displayName: 'Attachment ID', + name: 'attachments', + type: 'string', + default: '', + placeholder: '1664279886239010824', + description: 'The attachment ID to associate with the message', + }, + ], + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { show: { resource: ['list'] } }, + options: [ + { + name: 'Add Member', + value: 'add', + description: 'Add a member to a list', + action: 'Add Member to List', + }, + { name: 'Custom API Call', value: '__CUSTOM_API_CALL__' }, + ], + default: 'add', + }, + { + displayName: 'List', + name: 'list', + type: 'resourceLocator', + default: { mode: 'id', value: '' }, + required: true, + description: 'The list you want to add the user to', + displayOptions: { show: { operation: ['add'], resource: ['list'] } }, + modes: [ + { + displayName: 'By ID', + name: 'id', + type: 'string', + validation: [], + placeholder: 'e.g. 99923132', + url: '', + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + validation: [], + placeholder: 'e.g. https://twitter.com/i/lists/99923132', + url: '', + }, + ], + }, + { + displayName: 'User', + name: 'user', + type: 'resourceLocator', + default: { mode: 'username', value: '' }, + required: true, + description: 'The user you want to add to the list', + displayOptions: { show: { operation: ['add'], resource: ['list'] } }, + modes: [ + { + displayName: 'By Username', + name: 'username', + type: 'string', + validation: [], + placeholder: 'e.g. n8n', + url: '', + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + validation: [], + placeholder: 'e.g. 1068479892537384960', + url: '', + }, + ], + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { show: { resource: ['tweet'] } }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create, quote, or reply to a tweet', + action: 'Create Tweet', + }, + { name: 'Delete', value: 'delete', description: 'Delete a tweet', action: 'Delete Tweet' }, + { name: 'Like', value: 'like', description: 'Like a tweet', action: 'Like Tweet' }, + { + name: 'Retweet', + value: 'retweet', + description: 'Retweet a tweet', + action: 'Retweet Tweet', + }, + { + name: 'Search', + value: 'search', + description: 'Search for tweets from the last seven days', + action: 'Search Tweets', + }, + { name: 'Custom API Call', value: '__CUSTOM_API_CALL__' }, + ], + default: 'create', + }, + { + displayName: 'Text', + name: 'text', + type: 'string', + typeOptions: { rows: 2 }, + default: '', + required: true, + displayOptions: { show: { operation: ['create'], resource: ['tweet'] } }, + description: + 'The text of the status update. URLs must be encoded. Links wrapped with the t.co shortener will affect character count', + }, + { + displayName: 'Options', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { show: { operation: ['create'], resource: ['tweet'] } }, + options: [ + { + displayName: 'Location ID', + name: 'location', + type: 'string', + placeholder: '4e696bef7e24d378', + default: '', + description: 'Location information for the tweet', + }, + { + displayName: 'Media ID', + name: 'attachments', + type: 'string', + default: '', + placeholder: '1664279886239010824', + description: 'The attachment ID to associate with the message', + }, + { + displayName: 'Quote a Tweet', + name: 'inQuoteToStatusId', + type: 'resourceLocator', + default: { mode: 'id', value: '' }, + description: 'The tweet being quoted', + modes: [ + { + displayName: 'By ID', + name: 'id', + type: 'string', + validation: [], + placeholder: 'e.g. 1187836157394112513', + url: '', + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + validation: [], + placeholder: 'e.g. https://twitter.com/n8n_io/status/1187836157394112513', + url: '', + }, + ], + }, + { + displayName: 'Reply to Tweet', + name: 'inReplyToStatusId', + type: 'resourceLocator', + default: { mode: 'id', value: '' }, + description: 'The tweet being replied to', + modes: [ + { + displayName: 'By ID', + name: 'id', + type: 'string', + validation: [], + placeholder: 'e.g. 1187836157394112513', + url: '', + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + validation: [], + placeholder: 'e.g. https://twitter.com/n8n_io/status/1187836157394112513', + url: '', + }, + ], + }, + ], + }, + { + displayName: 'Locations are not supported due to Twitter V2 API limitations', + name: 'noticeLocation', + type: 'notice', + displayOptions: { show: { '/additionalFields.location': [''] } }, + default: '', + }, + { + displayName: 'Attachements are not supported due to Twitter V2 API limitations', + name: 'noticeAttachments', + type: 'notice', + displayOptions: { show: { '/additionalFields.attachments': [''] } }, + default: '', + }, + { + displayName: 'Tweet', + name: 'tweetDeleteId', + type: 'resourceLocator', + default: { mode: 'id', value: '' }, + required: true, + description: 'The tweet to delete', + displayOptions: { show: { resource: ['tweet'], operation: ['delete'] } }, + modes: [ + { + displayName: 'By ID', + name: 'id', + type: 'string', + validation: [], + placeholder: 'e.g. 1187836157394112513', + url: '', + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + validation: [], + placeholder: 'e.g. https://twitter.com/n8n_io/status/1187836157394112513', + url: '', + }, + ], + }, + { + displayName: 'Tweet', + name: 'tweetId', + type: 'resourceLocator', + default: { mode: 'id', value: '' }, + required: true, + description: 'The tweet to like', + displayOptions: { show: { operation: ['like'], resource: ['tweet'] } }, + modes: [ + { + displayName: 'By ID', + name: 'id', + type: 'string', + validation: [], + placeholder: 'e.g. 1187836157394112513', + url: '', + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + validation: [], + placeholder: 'e.g. https://twitter.com/n8n_io/status/1187836157394112513', + url: '', + }, + ], + }, + { + displayName: 'Search Term', + name: 'searchText', + type: 'string', + required: true, + default: '', + placeholder: 'e.g. automation', + displayOptions: { show: { operation: ['search'], resource: ['tweet'] } }, + description: + 'A UTF-8, URL-encoded search query of 500 characters maximum, including operators. Queries may additionally be limited by complexity. Check the searching examples here.', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + description: 'Whether to return all results or only up to a given limit', + displayOptions: { show: { resource: ['tweet'], operation: ['search'] } }, + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 50, + description: 'Max number of results to return', + typeOptions: { minValue: 1 }, + displayOptions: { show: { resource: ['tweet'], operation: ['search'], returnAll: [false] } }, + }, + { + displayName: 'Options', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { show: { operation: ['search'], resource: ['tweet'] } }, + options: [ + { + displayName: 'Sort Order', + name: 'sortOrder', + type: 'options', + options: [ + { name: 'Recent', value: 'recency' }, + { name: 'Relevant', value: 'relevancy' }, + ], + description: 'The order in which to return results', + default: 'recency', + }, + { + displayName: 'After', + name: 'startTime', + type: 'dateTime', + default: '', + description: + "Tweets before this date will not be returned. This date must be within the last 7 days if you don't have Academic Research access.", + }, + { + displayName: 'Before', + name: 'endTime', + type: 'dateTime', + default: '', + description: + "Tweets after this date will not be returned. This date must be within the last 7 days if you don't have Academic Research access.", + }, + { + displayName: 'Tweet Fields', + name: 'tweetFieldsObject', + type: 'multiOptions', + options: [ + { name: 'Attachments', value: 'attachments' }, + { name: 'Author ID', value: 'author_id' }, + { name: 'Context Annotations', value: 'context_annotations' }, + { name: 'Conversation ID', value: 'conversation_id' }, + { name: 'Created At', value: 'created_at' }, + { name: 'Edit Controls', value: 'edit_controls' }, + { name: 'Entities', value: 'entities' }, + { name: 'Geo', value: 'geo' }, + { name: 'ID', value: 'id' }, + { name: 'In Reply To User ID', value: 'in_reply_to_user_id' }, + { name: 'Lang', value: 'lang' }, + { name: 'Non Public Metrics', value: 'non_public_metrics' }, + { name: 'Public Metrics', value: 'public_metrics' }, + { name: 'Organic Metrics', value: 'organic_metrics' }, + { name: 'Promoted Metrics', value: 'promoted_metrics' }, + { name: 'Possibly Sensitive', value: 'possibly_sensitive' }, + { name: 'Referenced Tweets', value: 'referenced_tweets' }, + { name: 'Reply Settings', value: 'reply_settings' }, + { name: 'Source', value: 'source' }, + { name: 'Text', value: 'text' }, + { name: 'Withheld', value: 'withheld' }, + ], + default: [], + description: + 'The fields to add to each returned tweet object. Default fields are: ID, text, edit_history_tweet_ids.', + }, + ], + }, + { + displayName: 'Tweet', + name: 'tweetId', + type: 'resourceLocator', + default: { mode: 'id', value: '' }, + required: true, + description: 'The tweet to retweet', + displayOptions: { show: { operation: ['retweet'], resource: ['tweet'] } }, + modes: [ + { + displayName: 'By ID', + name: 'id', + type: 'string', + validation: [], + placeholder: 'e.g. 1187836157394112513', + url: '', + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + validation: [], + placeholder: 'e.g. https://twitter.com/n8n_io/status/1187836157394112513', + url: '', + }, + ], + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { show: { resource: ['user'] } }, + options: [ + { + name: 'Get', + value: 'searchUser', + description: 'Retrieve a user by username', + action: 'Get User', + }, + { name: 'Custom API Call', value: '__CUSTOM_API_CALL__' }, + ], + default: 'searchUser', + }, + { + displayName: 'User', + name: 'user', + type: 'resourceLocator', + default: { mode: 'username', value: '' }, + required: true, + description: 'The user you want to search', + displayOptions: { + show: { operation: ['searchUser'], resource: ['user'] }, + hide: { me: [true] }, + }, + modes: [ + { + displayName: 'By Username', + name: 'username', + type: 'string', + validation: [], + placeholder: 'e.g. n8n', + url: '', + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + validation: [], + placeholder: 'e.g. 1068479892537384960', + url: '', + }, + ], + }, + { + displayName: 'Me', + name: 'me', + type: 'boolean', + displayOptions: { show: { operation: ['searchUser'], resource: ['user'] } }, + default: false, + description: 'Whether you want to search the authenticated user', + }, + ], + iconUrl: 'icons/n8n-nodes-base/dist/nodes/Twitter/x.svg', + codex: { + categories: ['Marketing & Content'], + resources: { + primaryDocumentation: [ + { url: 'https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.twitter/' }, + ], + credentialDocumentation: [{ url: 'https://docs.n8n.io/credentials/twitter' }], + }, + alias: ['Tweet', 'Twitter', 'X', 'X API'], + }, +}; + +export const twitterV1: INodeTypeDescription = { + displayName: 'X (Formerly Twitter)', + name: 'n8n-nodes-base.twitter', + group: ['output'], + subtitle: '={{$parameter["operation"] + ":" + $parameter["resource"]}}', + description: 'Consume Twitter API', + defaultVersion: 2, + version: 1, + defaults: { name: 'Twitter' }, + inputs: ['main'], + outputs: ['main'], + credentials: [{ name: 'twitterOAuth1Api', required: true }], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { name: 'Direct Message', value: 'directMessage' }, + { name: 'Tweet', value: 'tweet' }, + ], + default: 'tweet', + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { show: { resource: ['directMessage'] } }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a direct message', + action: 'Create a direct message', + }, + ], + default: 'create', + }, + { + displayName: 'User ID', + name: 'userId', + type: 'string', + required: true, + default: '', + displayOptions: { show: { operation: ['create'], resource: ['directMessage'] } }, + description: 'The ID of the user who should receive the direct message', + }, + { + displayName: 'Text', + name: 'text', + type: 'string', + required: true, + default: '', + displayOptions: { show: { operation: ['create'], resource: ['directMessage'] } }, + description: + 'The text of your Direct Message. URL encode as necessary. Max length of 10,000 characters.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { show: { operation: ['create'], resource: ['directMessage'] } }, + options: [ + { + displayName: 'Attachment', + name: 'attachment', + type: 'string', + default: 'data', + description: + 'Name of the binary property which contain data that should be added to the direct message as attachment', + }, + ], + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { show: { resource: ['tweet'] } }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create or reply a tweet', + action: 'Create a tweet', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a tweet', + action: 'Delete a tweet', + }, + { name: 'Like', value: 'like', description: 'Like a tweet', action: 'Like a tweet' }, + { + name: 'Retweet', + value: 'retweet', + description: 'Retweet a tweet', + action: 'Retweet a tweet', + }, + { + name: 'Search', + value: 'search', + description: 'Search tweets', + action: 'Search for tweets', + }, + ], + default: 'create', + }, + { + displayName: 'Text', + name: 'text', + type: 'string', + required: true, + default: '', + displayOptions: { show: { operation: ['create'], resource: ['tweet'] } }, + description: + 'The text of the status update. URL encode as necessary. t.co link wrapping will affect character counts.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { show: { operation: ['create'], resource: ['tweet'] } }, + options: [ + { + displayName: 'Attachments', + name: 'attachments', + type: 'string', + default: 'data', + description: + 'Name of the binary properties which contain data which should be added to tweet as attachment. Multiple ones can be comma-separated.', + }, + { + displayName: 'Display Coordinates', + name: 'displayCoordinates', + type: 'boolean', + default: false, + description: + 'Whether or not to put a pin on the exact coordinates a Tweet has been sent from', + }, + { + displayName: 'In Reply to Tweet', + name: 'inReplyToStatusId', + type: 'string', + default: '', + description: 'The ID of an existing status that the update is in reply to', + }, + { + displayName: 'Location', + name: 'locationFieldsUi', + type: 'fixedCollection', + placeholder: 'Add Location', + default: {}, + description: 'Subscriber location information.n', + options: [ + { + name: 'locationFieldsValues', + displayName: 'Location', + values: [ + { + displayName: 'Latitude', + name: 'latitude', + type: 'string', + required: true, + description: 'The location latitude', + default: '', + }, + { + displayName: 'Longitude', + name: 'longitude', + type: 'string', + required: true, + description: 'The location longitude', + default: '', + }, + ], + }, + ], + }, + { + displayName: 'Possibly Sensitive', + name: 'possiblySensitive', + type: 'boolean', + default: false, + description: + 'Whether you are uploading Tweet media that might be considered sensitive content such as nudity, or medical procedures', + }, + ], + }, + { + displayName: 'Tweet ID', + name: 'tweetId', + type: 'string', + required: true, + default: '', + displayOptions: { show: { operation: ['delete'], resource: ['tweet'] } }, + description: 'The ID of the tweet to delete', + }, + { + displayName: 'Search Text', + name: 'searchText', + type: 'string', + required: true, + default: '', + displayOptions: { show: { operation: ['search'], resource: ['tweet'] } }, + description: + 'A UTF-8, URL-encoded search query of 500 characters maximum, including operators. Queries may additionally be limited by complexity. Check the searching examples here.', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { show: { operation: ['search'], resource: ['tweet'] } }, + default: false, + description: 'Whether to return all results or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { show: { operation: ['search'], resource: ['tweet'], returnAll: [false] } }, + typeOptions: { minValue: 1 }, + default: 50, + description: 'Max number of results to return', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { show: { operation: ['search'], resource: ['tweet'] } }, + options: [ + { + displayName: 'Include Entities', + name: 'includeEntities', + type: 'boolean', + default: false, + description: 'Whether the entities node will be included', + }, + { + displayName: 'Language Name or ID', + name: 'lang', + type: 'options', + typeOptions: { loadOptionsMethod: 'getLanguages' }, + default: '', + description: + 'Restricts tweets to the given language, given by an ISO 639-1 code. Language detection is best-effort. Choose from the list, or specify an ID using an expression.', + }, + { + displayName: 'Location', + name: 'locationFieldsUi', + type: 'fixedCollection', + placeholder: 'Add Location', + default: {}, + description: 'Subscriber location information.n', + options: [ + { + name: 'locationFieldsValues', + displayName: 'Location', + values: [ + { + displayName: 'Latitude', + name: 'latitude', + type: 'string', + required: true, + description: 'The location latitude', + default: '', + }, + { + displayName: 'Longitude', + name: 'longitude', + type: 'string', + required: true, + description: 'The location longitude', + default: '', + }, + { + displayName: 'Radius', + name: 'radius', + type: 'options', + options: [ + { name: 'Milles', value: 'mi' }, + { name: 'Kilometers', value: 'km' }, + ], + required: true, + description: + 'Returns tweets by users located within a given radius of the given latitude/longitude', + default: '', + }, + { + displayName: 'Distance', + name: 'distance', + type: 'number', + typeOptions: { minValue: 0 }, + required: true, + default: '', + }, + ], + }, + ], + }, + { + displayName: 'Result Type', + name: 'resultType', + type: 'options', + options: [ + { + name: 'Mixed', + value: 'mixed', + description: 'Include both popular and real time results in the response', + }, + { + name: 'Recent', + value: 'recent', + description: 'Return only the most recent results in the response', + }, + { + name: 'Popular', + value: 'popular', + description: 'Return only the most popular results in the response', + }, + ], + default: 'mixed', + description: 'Specifies what type of search results you would prefer to receive', + }, + { + displayName: 'Tweet Mode', + name: 'tweetMode', + type: 'options', + options: [ + { name: 'Compatibility', value: 'compat' }, + { name: 'Extended', value: 'extended' }, + ], + default: 'compat', + description: + 'When the extended mode is selected, the response contains the entire untruncated text of the Tweet', + }, + { + displayName: 'Until', + name: 'until', + type: 'dateTime', + default: '', + description: 'Returns tweets created before the given date', + }, + ], + }, + { + displayName: 'Tweet ID', + name: 'tweetId', + type: 'string', + required: true, + default: '', + displayOptions: { show: { operation: ['like'], resource: ['tweet'] } }, + description: 'The ID of the tweet', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { show: { operation: ['like'], resource: ['tweet'] } }, + options: [ + { + displayName: 'Include Entities', + name: 'includeEntities', + type: 'boolean', + default: false, + description: 'Whether the entities will be omitted', + }, + ], + }, + { + displayName: 'Tweet ID', + name: 'tweetId', + type: 'string', + required: true, + default: '', + displayOptions: { show: { operation: ['retweet'], resource: ['tweet'] } }, + description: 'The ID of the tweet', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { show: { operation: ['retweet'], resource: ['tweet'] } }, + options: [ + { + displayName: 'Trim User', + name: 'trimUser', + type: 'boolean', + default: false, + description: + 'Whether each tweet returned in a timeline will include a user object including only the status authors numerical ID', + }, + ], + }, + ], + iconUrl: 'icons/n8n-nodes-base/dist/nodes/Twitter/x.svg', +}; diff --git a/packages/editor-ui/src/stores/credentials.store.ts b/packages/editor-ui/src/stores/credentials.store.ts index 00f0dbfff1..446ea4a35d 100644 --- a/packages/editor-ui/src/stores/credentials.store.ts +++ b/packages/editor-ui/src/stores/credentials.store.ts @@ -132,9 +132,9 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, { getNodesWithAccess() { return (credentialTypeName: string) => { const nodeTypesStore = useNodeTypesStore(); - const allLatestNodeTypes: INodeTypeDescription[] = nodeTypesStore.allLatestNodeTypes; + const allNodeTypes: INodeTypeDescription[] = nodeTypesStore.allNodeTypes; - return allLatestNodeTypes.filter((nodeType: INodeTypeDescription) => { + return allNodeTypes.filter((nodeType: INodeTypeDescription) => { if (!nodeType.credentials) { return false; } diff --git a/packages/editor-ui/src/stores/nodeTypes.store.ts b/packages/editor-ui/src/stores/nodeTypes.store.ts index 21ffb1a746..d3f1880a85 100644 --- a/packages/editor-ui/src/stores/nodeTypes.store.ts +++ b/packages/editor-ui/src/stores/nodeTypes.store.ts @@ -6,12 +6,7 @@ import { getResourceLocatorResults, getResourceMapperFields, } from '@/api/nodeTypes'; -import { - DEFAULT_NODETYPE_VERSION, - HTTP_REQUEST_NODE_TYPE, - STORES, - CREDENTIAL_ONLY_HTTP_NODE_VERSION, -} from '@/constants'; +import { HTTP_REQUEST_NODE_TYPE, STORES, CREDENTIAL_ONLY_HTTP_NODE_VERSION } from '@/constants'; import type { INodeTypesState, DynamicNodeParameters } from '@/Interface'; import { addHeaders, addNodeTranslation } from '@/plugins/i18n'; import { omit } from '@/utils/typesUtils'; @@ -35,10 +30,9 @@ import { getCredentialTypeName, isCredentialOnlyNodeType, } from '@/utils/credentialOnlyNodes'; +import { groupNodeTypesByNameAndType } from '@/utils/nodeTypes/nodeTypeTransforms'; -function getNodeVersions(nodeType: INodeTypeDescription) { - return Array.isArray(nodeType.version) ? nodeType.version : [nodeType.version]; -} +export type NodeTypesStore = ReturnType; export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, { state: (): INodeTypesState => ({ @@ -196,36 +190,11 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, { }, actions: { setNodeTypes(newNodeTypes: INodeTypeDescription[] = []): void { - const nodeTypes = newNodeTypes.reduce>>( - (acc, newNodeType) => { - const newNodeVersions = getNodeVersions(newNodeType); - - if (newNodeVersions.length === 0) { - const singleVersion = { [DEFAULT_NODETYPE_VERSION]: newNodeType }; - - acc[newNodeType.name] = singleVersion; - return acc; - } - - for (const version of newNodeVersions) { - // Node exists with the same name - if (acc[newNodeType.name]) { - acc[newNodeType.name][version] = Object.assign( - acc[newNodeType.name][version] ?? {}, - newNodeType, - ); - } else { - acc[newNodeType.name] = Object.assign(acc[newNodeType.name] ?? {}, { - [version]: newNodeType, - }); - } - } - - return acc; - }, - { ...this.nodeTypes }, - ); - this.nodeTypes = nodeTypes; + const nodeTypes = groupNodeTypesByNameAndType(newNodeTypes); + this.nodeTypes = { + ...this.nodeTypes, + ...nodeTypes, + }; }, removeNodeTypes(nodeTypesToRemove: INodeTypeDescription[]): void { this.nodeTypes = nodeTypesToRemove.reduce( diff --git a/packages/editor-ui/src/utils/nodeTypes/nodeTypeTransforms.ts b/packages/editor-ui/src/utils/nodeTypes/nodeTypeTransforms.ts new file mode 100644 index 0000000000..a068f86c59 --- /dev/null +++ b/packages/editor-ui/src/utils/nodeTypes/nodeTypeTransforms.ts @@ -0,0 +1,57 @@ +import type { INodeTypeDescription } from 'n8n-workflow'; +import type { NodeTypesByTypeNameAndVersion } from '@/Interface'; +import { DEFAULT_NODETYPE_VERSION } from '@/constants'; + +export function getNodeVersions(nodeType: INodeTypeDescription) { + return Array.isArray(nodeType.version) ? nodeType.version : [nodeType.version]; +} + +/** + * Groups given node types by their name and version + * + * @example + * const nodeTypes = [ + * { name: 'twitter', version: '1', ... }, + * { name: 'twitter', version: '2', ... }, + * ] + * + * const groupedNodeTypes = groupNodeTypesByNameAndType(nodeTypes); + * // { + * // twitter: { + * // 1: { name: 'twitter', version: '1', ... }, + * // 2: { name: 'twitter', version: '2', ... }, + * // } + * // } + */ +export function groupNodeTypesByNameAndType( + nodeTypes: INodeTypeDescription[], +): NodeTypesByTypeNameAndVersion { + const groupedNodeTypes = nodeTypes.reduce((groups, nodeType) => { + const newNodeVersions = getNodeVersions(nodeType); + + if (newNodeVersions.length === 0) { + const singleVersion = { [DEFAULT_NODETYPE_VERSION]: nodeType }; + + groups[nodeType.name] = singleVersion; + return groups; + } + + for (const version of newNodeVersions) { + // Node exists with the same name + if (groups[nodeType.name]) { + groups[nodeType.name][version] = Object.assign( + groups[nodeType.name][version] ?? {}, + nodeType, + ); + } else { + groups[nodeType.name] = Object.assign(groups[nodeType.name] ?? {}, { + [version]: nodeType, + }); + } + } + + return groups; + }, {}); + + return groupedNodeTypes; +}