From 8c8fa4167e7d633921fdd567345e187463b4ace1 Mon Sep 17 00:00:00 2001 From: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com> Date: Wed, 21 Sep 2022 15:44:45 +0200 Subject: [PATCH] feat: add resource locator parameter (#3932) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ Added resource locator interfaces to `n8n-workflow` package * ✅ Updating Trello node to use resource locator property type * ✨ Added resource locator prop to Delete Board` Trello operation * ✔️ Fiixing linting errors in Trello node * ✨ Added list mode to Trello test node * ⚡ Updating resource locator modes interface * ⚡ Updating Trello test node validation messages and placeholders * N8N-4175 resource locator component (#3812) * ✨ Implemented initial version of resource locator component * ✨ Implemented front-end validation for resource locator component. Improved responsiveness. Minor refactoring. * ⚡ Setting resource locator default state to list. Updating hover states and expand icon. * 🔨 Moving resource locator component to `ParameterInput` from `ParameterInputFull * 🔨 Moving `ResourceLocator` to a separate Vue component * 🔨 Implementing expression and drag'n'drop support in ResourceLocator` component * 🔨 Cleaning up `ResourceLocator` component code * ✨ Implemented resource locator selected mode persistance * 💄 Minor refactoring and fixes in `ResourceLocator` * 🔨 Updating `ResourceLocator` front-end validation logic * ⚡ Saving resource locator mode in node parameters * 💄 Updating the `ResourceLocator` component based on the design review * 🐛 Fixing resource locator mode parameters handling when loading node parameter values on front-end * 💄 Removing leftover unused CSS * ⚡ Updating interfaces to support resource locator value types * ⚡ Updating `ResourceLocator` component to work with object parameter values * 🔨 Cleaning up `ResourceLocator` and related components code * ⚡ Preventing `DraggableTarget` to be sticky if disabled * 🐛 Fixing a bug with resource locator value parameter * 👌 Adding new type alias for all possible node parameter value types * 👌 Updating `ResourceLocator` and related components based on PR review feedback * ⚡ Adding disabled mode to `ResourceLocator` component, fixing expression handling, minor refactoring. * 💄 Updating disabled state styling in `ResourceLocator` component * ⚡ Setting correct default value for test node and removing unnecessary logic * 💄 Added regex URL validation to Trello test node * ✨ Updating Trello test node with another (list mode only) test case * ✔️ Fixing linting error in Trello node * 🔨 Removing hardcoded custom modes and modes order * Add value extractor to routing node (#3777) * :sparkles: add value extractor to routing node * :sparkles: add value extractor to property modes * :loud_sound: improve error logging for value extractor * :fire: remove old extractValue methods from RoutingNode * :zap: extractValue inside getNodeParameter * :fire: remove extract value test from RoutingNode * :sparkles: make value extraction optional * :goal_net: move extract value so proper error messages are sent * :rotating_light: readd accidentally removed eslint-disable * :sparkles: add resource locator support extractValue * :rotating_light: remove unused import * :bug: fix getting value of resource locator * 💄 Updating resource locator component styling and handling reset value action * :sparkles: create v2 of Trello node for resource locator * 💄 Updating ResourceLocator droppable & activeDrop classes and removing input padding-right * ⚡ Updating Trello test node with single-mode test case * ⚡ Updating field names in Trello node to avoid name clash * 💄 Updating test Trello node mode order and board:update parameter name * 💄 Updating test node parameter names and display options * List mode search endpoint (#3936) * :construction: super basic version of the search endpoint This version is built using a hacked up version of the Google Drive node. I need to properly create a v2 for it but it's does work. * :construction: fixed up type errors and return urls * :sparkles: add v3 of Google Drive node with RLC * :sparkles: add RLC to Google Drive Shared Drive operations * :recycle: address some small changes requested in review * :bug: move list search out of /nodes/ and add check for required param * :sparkles: google drive folder search * :sparkles: google drive search sort by name * :sparkles: add searchable flag for RLC * :pencil2: fix google drive wording for v3 * Trello and Airtable search backend (#3974) * :sparkles: add search to Trello boards * :sparkles: add RLC to Trello cards * :recycle: use new versioning system for Trello v2 * :bug: move list search out of /nodes/ and add check for required param * :sparkles: re-add trello search methods * :goal_net: throw error if RLC search isn't sent a method name This will likely be removed when the declarative style of search has been added. * :sparkles: add requires filter field to RLC search * :sparkles: add searchable flag to Trello searches * :sparkles: add RLC for cardId and boardId on all operations * :sparkles: add ID and URL RLC to Airtable * N8 n 4179 resource locator list mode (#3933) * ✨ Implemented initial version of list mode dropdown * ✨ Handling mode switching and expression support in list mode * 🔨 Removing `sortedModes` references * ⚡ Fixing list mode UI after latest mege * 💄 Updating padding-right for input fields with suffix slots * ✨ Minor fixes to validation, mode switching logic and styling * update error * 2 or more regex * update regex to be more strict * remove expr colors * update hint * :construction: super basic version of the search endpoint This version is built using a hacked up version of the Google Drive node. I need to properly create a v2 for it but it's does work. * :construction: fixed up type errors and return urls * begin list impl * :sparkles: add v3 of Google Drive node with RLC * fix ts issue * introduce dropdown * add more behavior * update design * show search * add filtering * push up selected * add keyboard nav * add loading * add caching * remove console * fix build issues * add debounce * fix click * keep event on focus * fix input size bug * add resource locator type * update type * update interface * update resource locator types * :sparkles: add search to Trello boards * :sparkles: add RLC to Google Drive Shared Drive operations * update * update name * add package * use stringify pckg * handle long vals * fix bug in url id modes * remove console log * add lazy loading * add lazy loading on filtering * clean up * make search clearable * add error state * :sparkles: add RLC to Trello cards * :recycle: address some small changes requested in review * :recycle: use new versioning system for Trello v2 * refactor a bit * fix how loading happens * clear after blur * update api * comment out test code * update api * relaod in case of error * update endpoint * :bug: move list search out of /nodes/ and add check for required param * :bug: move list search out of /nodes/ and add check for required param * update req handling * update endpoint * :sparkles: re-add trello search methods * :goal_net: throw error if RLC search isn't sent a method name This will likely be removed when the declarative style of search has been added. * get api to work * update scroll handling * :sparkles: google drive folder search * :sparkles: add requires filter field to RLC search * :sparkles: google drive search sort by name * remove console * :sparkles: add searchable flag for RLC * :sparkles: add searchable flag to Trello searches * update searchable * :sparkles: add RLC for cardId and boardId on all operations * :sparkles: add ID and URL RLC to Airtable * fix up search * remove extra padding * add link button * update popper pos * format * fix formating * update mode change * add name urls * update regex and errors * upate error * update errors * update airtable regex * update trello regex rules * udpate param name * update * update param * update param * update drive node * update params * add keyboard nav * fix bug * update airtable default mode * fix default value issue * hide long selected value * update duplicate reqs * update node * clean up impl * dedupe resources * fix up nv * resort params * update icon * set placeholders * default to id mode * add telemetry * add refresh opt * clean up tmp val * revert test change * make placeholder optional * update validation * remove description as param hint * support more general values * fix links on long names * update resource item styles * update pos * update icon color * update link alt * check if required * move validation to workflow * update naming * only show warning at param level * show right border on focus * fix hover on all item * fix long names bug * fix expr bug * add expr * update legacy mode * fix up impl * clean up node types * clean up types * remove unnessary type * clean up types * clean up types * clean up types * clea n up localizaiton * remove unused key * clean up helpers * clean up paraminput * clean up paraminputfull * refactor into one loop * update component * update class names * update prop types * update name cases * update casing * clean up classes * clean up resource locator * update drop handling * update mode * add url for link mode * clear value by default * add placeholder * remove legacy hint * handle expr in legacy * fix typos * revert padding change * fix up spacing * update to link component * support urls for id * fix replacement * build Co-authored-by: Milorad Filipovic Co-authored-by: Valya Bullions * refactor: Resource locator review changes (#4109) * ✨ Implemented initial version of list mode dropdown * ✨ Handling mode switching and expression support in list mode * 🔨 Removing `sortedModes` references * ⚡ Fixing list mode UI after latest mege * 💄 Updating padding-right for input fields with suffix slots * ✨ Minor fixes to validation, mode switching logic and styling * update error * 2 or more regex * update regex to be more strict * remove expr colors * update hint * :construction: super basic version of the search endpoint This version is built using a hacked up version of the Google Drive node. I need to properly create a v2 for it but it's does work. * :construction: fixed up type errors and return urls * begin list impl * :sparkles: add v3 of Google Drive node with RLC * fix ts issue * introduce dropdown * add more behavior * update design * show search * add filtering * push up selected * add keyboard nav * add loading * add caching * remove console * fix build issues * add debounce * fix click * keep event on focus * fix input size bug * add resource locator type * update type * update interface * update resource locator types * :sparkles: add search to Trello boards * :sparkles: add RLC to Google Drive Shared Drive operations * update * update name * add package * use stringify pckg * handle long vals * fix bug in url id modes * remove console log * add lazy loading * add lazy loading on filtering * clean up * make search clearable * add error state * :sparkles: add RLC to Trello cards * :recycle: address some small changes requested in review * :recycle: use new versioning system for Trello v2 * refactor a bit * fix how loading happens * clear after blur * update api * comment out test code * update api * relaod in case of error * update endpoint * :bug: move list search out of /nodes/ and add check for required param * :bug: move list search out of /nodes/ and add check for required param * update req handling * update endpoint * :sparkles: re-add trello search methods * :goal_net: throw error if RLC search isn't sent a method name This will likely be removed when the declarative style of search has been added. * get api to work * update scroll handling * :sparkles: google drive folder search * :sparkles: add requires filter field to RLC search * :sparkles: google drive search sort by name * remove console * :sparkles: add searchable flag for RLC * :sparkles: add searchable flag to Trello searches * update searchable * :sparkles: add RLC for cardId and boardId on all operations * :sparkles: add ID and URL RLC to Airtable * fix up search * remove extra padding * add link button * update popper pos * format * fix formating * update mode change * add name urls * update regex and errors * upate error * update errors * update airtable regex * update trello regex rules * udpate param name * update * update param * update param * update drive node * update params * add keyboard nav * fix bug * update airtable default mode * fix default value issue * hide long selected value * update duplicate reqs * update node * clean up impl * dedupe resources * fix up nv * resort params * update icon * set placeholders * default to id mode * add telemetry * add refresh opt * clean up tmp val * revert test change * make placeholder optional * update validation * remove description as param hint * support more general values * fix links on long names * update resource item styles * update pos * update icon color * update link alt * check if required * move validation to workflow * update naming * only show warning at param level * show right border on focus * fix hover on all item * fix long names bug * :recycle: refactor extractValue to allow multiple props with same name * :recycle: use correct import for displayParameterPath * fix expr bug * add expr * update legacy mode * fix up impl * clean up node types * clean up types * :recycle: remove new version of google drive node * :recycle: removed versioned Trello node for RLC * remove unnessary type * :recycle: remove versioned Airtable not for RLC * clean up types * clean up types * clean up types * clea n up localizaiton * remove unused key * clean up helpers * clean up paraminput * clean up paraminputfull * refactor into one loop * update component * update class names * update prop types * update name cases * update casing * clean up classes * :speech_balloon: updated RLC URL regex error wording * clean up resource locator * update drop handling * update mode * :speech_balloon: reword value extractor errors * :rotating_light: remove unneeded eslint ignores for RLC modes * :speech_balloon: update Trello 400 error message * :rotating_light: re-add removed types in editor-ui Also ts-ignore something that was clean up in another commit. I've added a comment to fix after someone else can look at it. * :speech_balloon: remove hints from Google Drive RLCs * :goal_net: rethrow correct errors in Trello node * :sparkles: add url for id mode on Google Drive * :fire: remove unused Google Drive file * :loud_sound: change console.error to use logger instead * :twisted_rightwards_arrows: fix bad merges * :recycle: small changes from review * :recycle: remove ts-ignore Co-authored-by: Milorad Filipovic Co-authored-by: Mutasem * fix build * update tests * fix bug with credential card * update popover component * fix expressions url * fix type issue * format * update alt * fix lint issues * fix eslint issues Co-authored-by: Milorad Filipovic Co-authored-by: Milorad FIlipović Co-authored-by: Valya <68596159+valya@users.noreply.github.com> Co-authored-by: Valya Bullions --- package-lock.json | 72 +- packages/cli/src/Server.ts | 111 ++- packages/cli/src/requests.d.ts | 19 + packages/core/src/ExtractValue.ts | 183 +++++ packages/core/src/LoadNodeListSearch.ts | 133 +++ packages/core/src/NodeExecuteFunctions.ts | 91 +-- packages/core/src/index.ts | 1 + .../src/components/N8nButton/Button.vue | 4 + .../__snapshots__/Button.spec.ts.snap | 10 +- .../src/components/N8nInput/Input.vue | 3 + .../src/components/N8nLink/Link.vue | 4 + packages/design-system/theme/src/input.scss | 4 +- packages/editor-ui/package.json | 1 + packages/editor-ui/src/Interface.ts | 21 +- packages/editor-ui/src/api/nodeTypes.ts | 11 + .../src/components/BreakpointsObserver.vue | 5 +- .../src/components/CredentialCard.vue | 2 +- .../src/components/CredentialsList.vue | 2 +- .../src/components/DraggableTarget.vue | 2 +- .../src/components/ExpressionEdit.vue | 2 + .../editor-ui/src/components/MainSidebar.vue | 4 +- .../src/components/NodeCredentials.vue | 2 +- .../src/components/ParameterInput.vue | 91 ++- .../src/components/ParameterInputExpanded.vue | 8 + .../src/components/ParameterInputFull.vue | 70 +- .../src/components/ParameterOptions.vue | 48 +- .../ResourceLocator/ResourceLocator.vue | 766 ++++++++++++++++++ .../ResourceLocatorDropdown.vue | 327 ++++++++ .../src/components/ResourceLocator/helpers.ts | 7 + packages/editor-ui/src/components/helpers.ts | 25 +- .../src/components/mixins/debounce.ts | 24 + .../src/components/mixins/genericHelpers.ts | 15 - packages/editor-ui/src/modules/nodeTypes.ts | 10 +- packages/editor-ui/src/modules/ui.ts | 2 +- .../src/plugins/i18n/locales/en.json | 18 + packages/editor-ui/src/plugins/icons.ts | 2 + packages/editor-ui/src/typeGuards.ts | 5 + .../editor-ui/src/views/CredentialsView.vue | 5 +- packages/editor-ui/src/views/NodeView.vue | 2 + .../src/views/TemplatesSearchView.vue | 3 +- .../nodes/Airtable/Airtable.node.ts | 131 ++- .../nodes/Google/Drive/GoogleDrive.node.ts | 430 +++++++--- .../nodes/Trello/AttachmentDescription.ts | 104 +-- .../nodes/Trello/BoardDescription.ts | 90 +- .../nodes/Trello/CardCommentDescription.ts | 91 ++- .../nodes/Trello/CardDescription.ts | 89 +- .../nodes/Trello/ChecklistDescription.ts | 157 ++-- .../nodes/Trello/GenericFunctions.ts | 3 + .../nodes/Trello/LabelDescription.ts | 157 +++- .../nodes-base/nodes/Trello/Trello.node.ts | 178 +++- packages/nodes-base/package.json | 2 +- packages/workflow/src/Expression.ts | 26 +- packages/workflow/src/Interfaces.ts | 160 +++- packages/workflow/src/NodeHelpers.ts | 78 +- packages/workflow/src/RoutingNode.ts | 13 +- packages/workflow/src/Workflow.ts | 8 +- packages/workflow/src/WorkflowDataProxy.ts | 8 +- packages/workflow/test/RoutingNode.test.ts | 4 +- 58 files changed, 3146 insertions(+), 698 deletions(-) create mode 100644 packages/core/src/ExtractValue.ts create mode 100644 packages/core/src/LoadNodeListSearch.ts create mode 100644 packages/editor-ui/src/components/ResourceLocator/ResourceLocator.vue create mode 100644 packages/editor-ui/src/components/ResourceLocator/ResourceLocatorDropdown.vue create mode 100644 packages/editor-ui/src/components/ResourceLocator/helpers.ts create mode 100644 packages/editor-ui/src/components/mixins/debounce.ts create mode 100644 packages/editor-ui/src/typeGuards.ts diff --git a/package-lock.json b/package-lock.json index 3bad542797b61..57c3b6cb4a93c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21856,22 +21856,6 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "dev": true }, - "node_modules/eslint-plugin-n8n-nodes-base": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-n8n-nodes-base/-/eslint-plugin-n8n-nodes-base-1.9.1.tgz", - "integrity": "sha512-7OQNP5DU3Lw1VgS+m6Ez+MqspRqtifEB2cM4n4q3Pxuw1P7HtAds+hBLIs6KFcTO1qINtaLr4l7XK5/h2XKThw==", - "dev": true, - "dependencies": { - "@typescript-eslint/utils": "^5.17.0", - "camel-case": "^4.1.2", - "indefinite": "^2.4.1", - "pascal-case": "^3.1.2", - "pluralize": "^8.0.0", - "prettier": "^2.7.1", - "sentence-case": "^3.0.4", - "title-case": "^3.0.3" - } - }, "node_modules/eslint-plugin-prettier": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz", @@ -52492,6 +52476,7 @@ "babel-eslint": "^10.0.1", "cross-env": "^7.0.2", "dateformat": "^3.0.3", + "fast-json-stable-stringify": "^2.1.0", "file-saver": "^2.0.2", "flatted": "^3.2.4", "jquery": "^3.4.1", @@ -52648,7 +52633,7 @@ "@types/tmp": "^0.2.0", "@types/uuid": "^8.3.2", "@types/xml2js": "^0.4.3", - "eslint-plugin-n8n-nodes-base": "^1.9.1", + "eslint-plugin-n8n-nodes-base": "^1.9.3", "gulp": "^4.0.0", "jest": "^27.4.7", "n8n-workflow": "~0.116.0", @@ -52665,6 +52650,23 @@ "node": ">=0.10" } }, + "packages/nodes-base/node_modules/eslint-plugin-n8n-nodes-base": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-n8n-nodes-base/-/eslint-plugin-n8n-nodes-base-1.9.3.tgz", + "integrity": "sha512-HcHTgcTuIudZE9vuTqjZkoG5GG7xn6wydL+ckqKpH7QkIK6p8wwDv/vL2SaDaRjT0vgO4/vzeUnCTQ/DQ2eZQg==", + "dev": true, + "dependencies": { + "@typescript-eslint/utils": "^5.17.0", + "camel-case": "^4.1.2", + "eslint-plugin-n8n-nodes-base": "^1.9.1", + "indefinite": "^2.4.1", + "pascal-case": "^3.1.2", + "pluralize": "^8.0.0", + "prettier": "^2.7.1", + "sentence-case": "^3.0.4", + "title-case": "^3.0.3" + } + }, "packages/nodes-base/node_modules/mongodb": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-4.9.1.tgz", @@ -70081,22 +70083,6 @@ } } }, - "eslint-plugin-n8n-nodes-base": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-n8n-nodes-base/-/eslint-plugin-n8n-nodes-base-1.9.1.tgz", - "integrity": "sha512-7OQNP5DU3Lw1VgS+m6Ez+MqspRqtifEB2cM4n4q3Pxuw1P7HtAds+hBLIs6KFcTO1qINtaLr4l7XK5/h2XKThw==", - "dev": true, - "requires": { - "@typescript-eslint/utils": "^5.17.0", - "camel-case": "^4.1.2", - "indefinite": "^2.4.1", - "pascal-case": "^3.1.2", - "pluralize": "^8.0.0", - "prettier": "^2.7.1", - "sentence-case": "^3.0.4", - "title-case": "^3.0.3" - } - }, "eslint-plugin-prettier": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz", @@ -81700,6 +81686,7 @@ "babel-eslint": "^10.0.1", "cross-env": "^7.0.2", "dateformat": "^3.0.3", + "fast-json-stable-stringify": "^2.1.0", "file-saver": "^2.0.2", "flatted": "^3.2.4", "jquery": "^3.4.1", @@ -81810,7 +81797,7 @@ "cheerio": "1.0.0-rc.6", "chokidar": "3.5.2", "cron": "~1.7.2", - "eslint-plugin-n8n-nodes-base": "^1.9.1", + "eslint-plugin-n8n-nodes-base": "^1.9.3", "eventsource": "^2.0.2", "fast-glob": "^3.2.5", "fflate": "^0.7.0", @@ -81869,6 +81856,23 @@ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==" }, + "eslint-plugin-n8n-nodes-base": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-n8n-nodes-base/-/eslint-plugin-n8n-nodes-base-1.9.3.tgz", + "integrity": "sha512-HcHTgcTuIudZE9vuTqjZkoG5GG7xn6wydL+ckqKpH7QkIK6p8wwDv/vL2SaDaRjT0vgO4/vzeUnCTQ/DQ2eZQg==", + "dev": true, + "requires": { + "@typescript-eslint/utils": "^5.17.0", + "camel-case": "^4.1.2", + "eslint-plugin-n8n-nodes-base": "^1.9.1", + "indefinite": "^2.4.1", + "pascal-case": "^3.1.2", + "pluralize": "^8.0.0", + "prettier": "^2.7.1", + "sentence-case": "^3.0.4", + "title-case": "^3.0.3" + } + }, "mongodb": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-4.9.1.tgz", diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 071095f1c5452..abd2b23ec34b5 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -46,17 +46,27 @@ import clientOAuth1, { RequestOptions } from 'oauth-1.0a'; // tested with all possible systems like Windows, Alpine on ARM, FreeBSD, ... import { compare } from 'bcryptjs'; -import { BinaryDataManager, Credentials, LoadNodeParameterOptions, UserSettings } from 'n8n-core'; +import { + BinaryDataManager, + Credentials, + LoadNodeParameterOptions, + LoadNodeListSearch, + UserSettings, +} from 'n8n-core'; import { ICredentialType, INodeCredentials, INodeCredentialsDetails, + INodeListSearchResult, INodeParameters, INodePropertyOptions, + INodeType, + INodeTypeDescription, INodeTypeNameVersion, ITelemetrySettings, LoggerProxy, + NodeHelpers, WebhookHttpMethod, WorkflowExecuteMode, } from 'n8n-workflow'; @@ -86,6 +96,7 @@ import { credentialsController } from './credentials/credentials.controller'; import { oauth2CredentialController } from './credentials/oauth2Credential.api'; import type { ExecutionRequest, + NodeListSearchRequest, NodeParameterOptionsRequest, OAuthRequest, WorkflowRequest, @@ -136,6 +147,7 @@ import { WebhookServer, WorkflowExecuteAdditionalData, } from '.'; +import { ResponseError } from './ResponseHelper'; require('body-parser-xml')(bodyParser); @@ -825,6 +837,103 @@ class App { ), ); + // Returns parameter values which normally get loaded from an external API or + // get generated dynamically + this.app.get( + `/${this.restEndpoint}/nodes-list-search`, + ResponseHelper.send( + async ( + req: NodeListSearchRequest, + res: express.Response, + ): Promise => { + const nodeTypeAndVersion = JSON.parse( + req.query.nodeTypeAndVersion, + ) as INodeTypeNameVersion; + + const { path, methodName } = req.query; + + if (!req.query.currentNodeParameters) { + throw new ResponseError('Parameter currentNodeParameters is required.', undefined, 400); + } + + const currentNodeParameters = JSON.parse( + req.query.currentNodeParameters, + ) as INodeParameters; + + let credentials: INodeCredentials | undefined; + + if (req.query.credentials) { + credentials = JSON.parse(req.query.credentials); + } + + const listSearchInstance = new LoadNodeListSearch( + nodeTypeAndVersion, + NodeTypes(), + path, + currentNodeParameters, + credentials, + ); + + const additionalData = await WorkflowExecuteAdditionalData.getBase( + req.user.id, + currentNodeParameters, + ); + + if (methodName) { + return listSearchInstance.getOptionsViaMethodName( + methodName, + additionalData, + req.query.filter, + req.query.paginationToken, + ); + } + + throw new ResponseError('Parameter methodName is required.', undefined, 400); + }, + ), + ); + + // Returns all the node-types + this.app.get( + `/${this.restEndpoint}/node-types`, + ResponseHelper.send( + async (req: express.Request, res: express.Response): Promise => { + const returnData: INodeTypeDescription[] = []; + const onlyLatest = req.query.onlyLatest === 'true'; + + const nodeTypes = NodeTypes(); + const allNodes = nodeTypes.getAll(); + + const getNodeDescription = (nodeType: INodeType): INodeTypeDescription => { + const nodeInfo: INodeTypeDescription = { ...nodeType.description }; + if (req.query.includeProperties !== 'true') { + // @ts-ignore + delete nodeInfo.properties; + } + return nodeInfo; + }; + + if (onlyLatest) { + allNodes.forEach((nodeData) => { + const nodeType = NodeHelpers.getVersionedNodeType(nodeData); + const nodeInfo: INodeTypeDescription = getNodeDescription(nodeType); + returnData.push(nodeInfo); + }); + } else { + allNodes.forEach((nodeData) => { + const allNodeTypes = NodeHelpers.getVersionedNodeTypeAll(nodeData); + allNodeTypes.forEach((element) => { + const nodeInfo: INodeTypeDescription = getNodeDescription(element); + returnData.push(nodeInfo); + }); + }); + } + + return returnData; + }, + ), + ); + this.app.get( `/${this.restEndpoint}/credential-translation`, ResponseHelper.send( diff --git a/packages/cli/src/requests.d.ts b/packages/cli/src/requests.d.ts index 05f5f682a0014..fed9b6088e150 100644 --- a/packages/cli/src/requests.d.ts +++ b/packages/cli/src/requests.d.ts @@ -287,6 +287,25 @@ export type NodeParameterOptionsRequest = AuthenticatedRequest< } >; +// ---------------------------------- +// /node-list-search +// ---------------------------------- + +export type NodeListSearchRequest = AuthenticatedRequest< + {}, + {}, + {}, + { + nodeTypeAndVersion: string; + methodName: string; + path: string; + currentNodeParameters: string; + credentials: string; + filter?: string; + paginationToken?: string; + } +>; + // ---------------------------------- // /tags // ---------------------------------- diff --git a/packages/core/src/ExtractValue.ts b/packages/core/src/ExtractValue.ts new file mode 100644 index 0000000000000..ffd31aee30b52 --- /dev/null +++ b/packages/core/src/ExtractValue.ts @@ -0,0 +1,183 @@ +import { + INode, + INodeParameters, + INodeProperties, + INodePropertyCollection, + INodePropertyOptions, + INodeType, + NodeOperationError, + NodeParameterValueType, + NodeHelpers, + LoggerProxy, +} from 'n8n-workflow'; + +function findPropertyFromParameterName( + parameterName: string, + nodeType: INodeType, + node: INode, + nodeParameters: INodeParameters, +): INodePropertyOptions | INodeProperties | INodePropertyCollection { + let property: INodePropertyOptions | INodeProperties | INodePropertyCollection | undefined; + const paramParts = parameterName.split('.'); + let currentParamPath = ''; + + const findProp = ( + name: string, + options: Array, + ): INodePropertyOptions | INodeProperties | INodePropertyCollection | undefined => { + return options.find( + (i) => + i.name === name && + NodeHelpers.displayParameterPath(nodeParameters, i, currentParamPath, node), + ); + }; + + // eslint-disable-next-line no-restricted-syntax + for (const p of paramParts) { + const param = p.split('[')[0]; + if (!property) { + property = findProp(param, nodeType.description.properties); + } else if ('options' in property && property.options) { + property = findProp(param, property.options); + currentParamPath += `.${param}`; + } else if ('values' in property) { + property = findProp(param, property.values); + currentParamPath += `.${param}`; + } else { + throw new Error(`Couldn't not find property "${parameterName}"`); + } + if (!property) { + throw new Error(`Couldn't not find property "${parameterName}"`); + } + } + if (!property) { + throw new Error(`Couldn't not find property "${parameterName}"`); + } + + return property; +} + +function executeRegexExtractValue( + value: string, + regex: RegExp, + parameterName: string, + parameterDisplayName: string, +): NodeParameterValueType | object { + const extracted = regex.exec(value); + if (!extracted) { + throw new Error( + `ERROR: ${parameterDisplayName} parameter's value is invalid. This is likely because the URL entered is incorrect`, + ); + } + if (extracted.length < 2 || extracted.length > 2) { + throw new Error( + `Property "${parameterName}" has an invalid extractValue regex "${regex.source}". extractValue expects exactly one group to be returned.`, + ); + } + return extracted[1]; +} + +function extractValueRLC( + value: NodeParameterValueType | object, + property: INodeProperties, + parameterName: string, +): NodeParameterValueType | object { + // Not an RLC value + if (typeof value !== 'object' || !value || !('mode' in value) || !('value' in value)) { + return value; + } + const modeProp = (property.modes ?? []).find((i) => i.name === value.mode); + if (!modeProp) { + return value.value; + } + if (!('extractValue' in modeProp) || !modeProp.extractValue) { + return value.value; + } + + if (typeof value.value !== 'string') { + let typeName: string | undefined = value.value?.constructor.name; + if (value.value === null) { + typeName = 'null'; + } else if (typeName === undefined) { + typeName = 'undefined'; + } + LoggerProxy.error( + `Only strings can be passed to extractValue. Parameter "${parameterName}" passed "${typeName}"`, + ); + throw new Error( + `ERROR: ${property.displayName} parameter's value is invalid. Please enter a valid ${modeProp.displayName}.`, + ); + } + + if (modeProp.extractValue.type !== 'regex') { + throw new Error( + `Property "${parameterName}" has an unknown extractValue type "${ + modeProp.extractValue.type as string + }"`, + ); + } + + const regex = new RegExp(modeProp.extractValue.regex); + return executeRegexExtractValue(value.value, regex, parameterName, property.displayName); +} + +function extractValueOther( + value: NodeParameterValueType | object, + property: INodeProperties | INodePropertyCollection, + parameterName: string, +): NodeParameterValueType | object { + if (!('extractValue' in property) || !property.extractValue) { + return value; + } + + if (typeof value !== 'string') { + let typeName: string | undefined = value?.constructor.name; + if (value === null) { + typeName = 'null'; + } else if (typeName === undefined) { + typeName = 'undefined'; + } + LoggerProxy.error( + `Only strings can be passed to extractValue. Parameter "${parameterName}" passed "${typeName}"`, + ); + throw new Error( + `ERROR: ${property.displayName} parameter's value is invalid. Please enter a valid value.`, + ); + } + + if (property.extractValue.type !== 'regex') { + throw new Error( + `Property "${parameterName}" has an unknown extractValue type "${ + property.extractValue.type as string + }"`, + ); + } + + const regex = new RegExp(property.extractValue.regex); + return executeRegexExtractValue(value, regex, parameterName, property.displayName); +} + +export function extractValue( + value: NodeParameterValueType | object, + parameterName: string, + node: INode, + nodeType: INodeType, +): NodeParameterValueType | object { + let property: INodePropertyOptions | INodeProperties | INodePropertyCollection; + try { + property = findPropertyFromParameterName(parameterName, nodeType, node, node.parameters); + + // Definitely doesn't have value extractor + if (!('type' in property)) { + return value; + } + + if (property.type === 'resourceLocator') { + return extractValueRLC(value, property, parameterName); + } + return extractValueOther(value, property, parameterName); + } catch (error) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + throw new NodeOperationError(node, error); + } +} diff --git a/packages/core/src/LoadNodeListSearch.ts b/packages/core/src/LoadNodeListSearch.ts new file mode 100644 index 0000000000000..f1f0313dcc434 --- /dev/null +++ b/packages/core/src/LoadNodeListSearch.ts @@ -0,0 +1,133 @@ +/* eslint-disable no-restricted-syntax */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/restrict-template-expressions */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ + +import { + INode, + INodeCredentials, + INodeListSearchResult, + INodeParameters, + INodeTypeNameVersion, + INodeTypes, + IWorkflowExecuteAdditionalData, + Workflow, +} from 'n8n-workflow'; + +// eslint-disable-next-line import/no-cycle +import { NodeExecuteFunctions } from '.'; + +const TEMP_NODE_NAME = 'Temp-Node'; +const TEMP_WORKFLOW_NAME = 'Temp-Workflow'; + +export class LoadNodeListSearch { + currentNodeParameters: INodeParameters; + + path: string; + + workflow: Workflow; + + constructor( + nodeTypeNameAndVersion: INodeTypeNameVersion, + nodeTypes: INodeTypes, + path: string, + currentNodeParameters: INodeParameters, + credentials?: INodeCredentials, + ) { + const nodeType = nodeTypes.getByNameAndVersion( + nodeTypeNameAndVersion.name, + nodeTypeNameAndVersion.version, + ); + this.currentNodeParameters = currentNodeParameters; + this.path = path; + if (nodeType === undefined) { + throw new Error( + `The node-type "${nodeTypeNameAndVersion.name} v${nodeTypeNameAndVersion.version}" is not known!`, + ); + } + + const nodeData: INode = { + parameters: currentNodeParameters, + id: 'uuid-1234', + name: TEMP_NODE_NAME, + type: nodeTypeNameAndVersion.name, + typeVersion: nodeTypeNameAndVersion.version, + position: [0, 0], + }; + if (credentials) { + nodeData.credentials = credentials; + } + + const workflowData = { + nodes: [nodeData], + connections: {}, + }; + + this.workflow = new Workflow({ + nodes: workflowData.nodes, + connections: workflowData.connections, + active: false, + nodeTypes, + }); + } + + /** + * Returns data of a fake workflow + * + * @returns + * @memberof LoadNodeParameterOptions + */ + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types + getWorkflowData() { + return { + name: TEMP_WORKFLOW_NAME, + active: false, + connections: {}, + nodes: Object.values(this.workflow.nodes), + createdAt: new Date(), + updatedAt: new Date(), + }; + } + + /** + * Returns the available options via a predefined method + * + * @param {string} methodName The name of the method of which to get the data from + * @param {IWorkflowExecuteAdditionalData} additionalData + * @returns {Promise} + * @memberof LoadNodeParameterOptions + */ + async getOptionsViaMethodName( + methodName: string, + additionalData: IWorkflowExecuteAdditionalData, + filter?: string, + paginationToken?: string, + ): Promise { + const node = this.workflow.getNode(TEMP_NODE_NAME); + + const nodeType = this.workflow.nodeTypes.getByNameAndVersion(node!.type, node?.typeVersion); + + if ( + !nodeType || + nodeType.methods === undefined || + nodeType.methods.listSearch === undefined || + nodeType.methods.listSearch[methodName] === undefined + ) { + throw new Error( + `The node-type "${node!.type}" does not have the method "${methodName}" defined!`, + ); + } + + const thisArgs = NodeExecuteFunctions.getLoadOptionsFunctions( + this.workflow, + node!, + this.path, + additionalData, + ); + + return nodeType.methods.listSearch[methodName].call(thisArgs, filter, paginationToken); + } +} diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 9ef61f1a978d4..75b50dafcc69b 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -52,7 +52,6 @@ import { NodeApiError, NodeHelpers, NodeOperationError, - NodeParameterValue, Workflow, WorkflowActivateMode, WorkflowDataProxy, @@ -60,6 +59,8 @@ import { LoggerProxy as Logger, IExecuteData, OAuth2GrantType, + IGetNodeParameterOptions, + NodeParameterValueType, NodeExecutionWithMetadata, IPairedItemData, } from 'n8n-workflow'; @@ -99,6 +100,7 @@ import { IWorkflowSettings, PLACEHOLDER_EMPTY_EXECUTION_ID, } from '.'; +import { extractValue } from './ExtractValue'; axios.defaults.timeout = 300000; // Prevent axios from adding x-form-www-urlencoded headers by default @@ -1672,9 +1674,7 @@ export function getNode(node: INode): INode { * Clean up parameter data to make sure that only valid data gets returned * INFO: Currently only converts Luxon Dates as we know for sure it will not be breaking */ -function cleanupParameterData( - inputData: NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[], -): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] { +function cleanupParameterData(inputData: NodeParameterValueType): NodeParameterValueType { if (inputData === null || inputData === undefined) { return inputData; } @@ -1691,7 +1691,9 @@ function cleanupParameterData( if (typeof inputData === 'object') { Object.keys(inputData).forEach((key) => { - inputData[key] = cleanupParameterData(inputData[key]); + inputData[key as keyof typeof inputData] = cleanupParameterData( + inputData[key as keyof typeof inputData], + ); }); } @@ -1725,7 +1727,8 @@ export function getNodeParameter( additionalKeys: IWorkflowDataProxyAdditionalKeys, executeData?: IExecuteData, fallbackValue?: any, -): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | object { + options?: IGetNodeParameterOptions, +): NodeParameterValueType | object { const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); if (nodeType === undefined) { throw new Error(`Node type "${node.type}" is not known so can not return parameter value!`); @@ -1759,6 +1762,11 @@ export function getNodeParameter( throw e; } + // This is outside the try/catch because it throws errors with proper messages + if (options?.extractValue) { + returnData = extractValue(returnData, parameterName, node, nodeType); + } + return returnData; } @@ -1931,12 +1939,8 @@ export function getExecutePollFunctions( getNodeParameter: ( parameterName: string, fallbackValue?: any, - ): - | NodeParameterValue - | INodeParameters - | NodeParameterValue[] - | INodeParameters[] - | object => { + options?: IGetNodeParameterOptions, + ): NodeParameterValueType | object => { const runExecutionData: IRunExecutionData | null = null; const itemIndex = 0; const runIndex = 0; @@ -1955,6 +1959,7 @@ export function getExecutePollFunctions( getAdditionalKeys(additionalData), undefined, fallbackValue, + options, ); }, getRestApiUrl: (): string => { @@ -2089,12 +2094,8 @@ export function getExecuteTriggerFunctions( getNodeParameter: ( parameterName: string, fallbackValue?: any, - ): - | NodeParameterValue - | INodeParameters - | NodeParameterValue[] - | INodeParameters[] - | object => { + options?: IGetNodeParameterOptions, + ): NodeParameterValueType | object => { const runExecutionData: IRunExecutionData | null = null; const itemIndex = 0; const runIndex = 0; @@ -2113,6 +2114,7 @@ export function getExecuteTriggerFunctions( getAdditionalKeys(additionalData), undefined, fallbackValue, + options, ); }, getRestApiUrl: (): string => { @@ -2312,12 +2314,8 @@ export function getExecuteFunctions( parameterName: string, itemIndex: number, fallbackValue?: any, - ): - | NodeParameterValue - | INodeParameters - | NodeParameterValue[] - | INodeParameters[] - | object => { + options?: IGetNodeParameterOptions, + ): NodeParameterValueType | object => { return getNodeParameter( workflow, runExecutionData, @@ -2331,6 +2329,7 @@ export function getExecuteFunctions( getAdditionalKeys(additionalData), executeData, fallbackValue, + options, ); }, getMode: (): WorkflowExecuteMode => { @@ -2590,12 +2589,8 @@ export function getExecuteSingleFunctions( getNodeParameter: ( parameterName: string, fallbackValue?: any, - ): - | NodeParameterValue - | INodeParameters - | NodeParameterValue[] - | INodeParameters[] - | object => { + options?: IGetNodeParameterOptions, + ): NodeParameterValueType | object => { return getNodeParameter( workflow, runExecutionData, @@ -2609,6 +2604,7 @@ export function getExecuteSingleFunctions( getAdditionalKeys(additionalData), executeData, fallbackValue, + options, ); }, getWorkflow: () => { @@ -2744,13 +2740,7 @@ export function getLoadOptionsFunctions( }, getCurrentNodeParameter: ( parameterPath: string, - ): - | NodeParameterValue - | INodeParameters - | NodeParameterValue[] - | INodeParameters[] - | object - | undefined => { + ): NodeParameterValueType | object | undefined => { const nodeParameters = additionalData.currentNodeParameters; if (parameterPath.charAt(0) === '&') { @@ -2768,12 +2758,8 @@ export function getLoadOptionsFunctions( getNodeParameter: ( parameterName: string, fallbackValue?: any, - ): - | NodeParameterValue - | INodeParameters - | NodeParameterValue[] - | INodeParameters[] - | object => { + options?: IGetNodeParameterOptions, + ): NodeParameterValueType | object => { const runExecutionData: IRunExecutionData | null = null; const itemIndex = 0; const runIndex = 0; @@ -2792,6 +2778,7 @@ export function getLoadOptionsFunctions( getAdditionalKeys(additionalData), undefined, fallbackValue, + options, ); }, getTimezone: (): string => { @@ -2899,12 +2886,8 @@ export function getExecuteHookFunctions( getNodeParameter: ( parameterName: string, fallbackValue?: any, - ): - | NodeParameterValue - | INodeParameters - | NodeParameterValue[] - | INodeParameters[] - | object => { + options?: IGetNodeParameterOptions, + ): NodeParameterValueType | object => { const runExecutionData: IRunExecutionData | null = null; const itemIndex = 0; const runIndex = 0; @@ -2923,6 +2906,7 @@ export function getExecuteHookFunctions( getAdditionalKeys(additionalData), undefined, fallbackValue, + options, ); }, getNodeWebhookUrl: (name: string): string | undefined => { @@ -3062,12 +3046,8 @@ export function getExecuteWebhookFunctions( getNodeParameter: ( parameterName: string, fallbackValue?: any, - ): - | NodeParameterValue - | INodeParameters - | NodeParameterValue[] - | INodeParameters[] - | object => { + options?: IGetNodeParameterOptions, + ): NodeParameterValueType | object => { const runExecutionData: IRunExecutionData | null = null; const itemIndex = 0; const runIndex = 0; @@ -3086,6 +3066,7 @@ export function getExecuteWebhookFunctions( getAdditionalKeys(additionalData), undefined, fallbackValue, + options, ); }, getParamsData(): object { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 11d130d02155d..6a327177ccc5c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -15,6 +15,7 @@ export * from './Constants'; export * from './Credentials'; export * from './Interfaces'; export * from './LoadNodeParameterOptions'; +export * from './LoadNodeListSearch'; export * from './NodeExecuteFunctions'; export * from './WorkflowExecute'; export { NodeExecuteFunctions, UserSettings }; diff --git a/packages/design-system/src/components/N8nButton/Button.vue b/packages/design-system/src/components/N8nButton/Button.vue index 1e83c156e5e3a..b073e977a48fa 100644 --- a/packages/design-system/src/components/N8nButton/Button.vue +++ b/packages/design-system/src/components/N8nButton/Button.vue @@ -382,6 +382,10 @@ $loading-overlay-background-color: rgba(255, 255, 255, 0); --button-hover-color: var(--color-success); } + &.tertiary { + --button-hover-color: var(--color-primary); + } + &.warning { --button-color: var(--color-warning); --button-active-color: var(--color-warning); diff --git a/packages/design-system/src/components/N8nButton/__tests__/__snapshots__/Button.spec.ts.snap b/packages/design-system/src/components/N8nButton/__tests__/__snapshots__/Button.spec.ts.snap index 7d1a29f43e689..38e7cb08e948f 100644 --- a/packages/design-system/src/components/N8nButton/__tests__/__snapshots__/Button.spec.ts.snap +++ b/packages/design-system/src/components/N8nButton/__tests__/__snapshots__/Button.spec.ts.snap @@ -1,17 +1,17 @@ // Vitest Snapshot v1 -exports[`components > N8nButton > overrides > should render correctly 1`] = `""`; +exports[`components > N8nButton > overrides > should render correctly 1`] = `""`; -exports[`components > N8nButton > props > icon > should render icon button 1`] = `""`; +exports[`components > N8nButton > props > icon > should render icon button 1`] = `""`; -exports[`components > N8nButton > props > loading > should render loading spinner 1`] = `""`; +exports[`components > N8nButton > props > loading > should render loading spinner 1`] = `""`; exports[`components > N8nButton > props > square > should render square button 1`] = ` -"" `; exports[`components > N8nButton > should render correctly 1`] = ` -"" `; diff --git a/packages/design-system/src/components/N8nInput/Input.vue b/packages/design-system/src/components/N8nInput/Input.vue index 22028189d97dc..4a54c3701fce1 100644 --- a/packages/design-system/src/components/N8nInput/Input.vue +++ b/packages/design-system/src/components/N8nInput/Input.vue @@ -51,6 +51,9 @@ export default Vue.extend({ disabled: { type: Boolean, }, + readonly: { + type: Boolean, + }, clearable: { type: Boolean, }, diff --git a/packages/design-system/src/components/N8nLink/Link.vue b/packages/design-system/src/components/N8nLink/Link.vue index 12dadeaac9427..b34caa1213147 100644 --- a/packages/design-system/src/components/N8nLink/Link.vue +++ b/packages/design-system/src/components/N8nLink/Link.vue @@ -72,6 +72,10 @@ export default Vue.extend({ .text { color: var(--color-text-base); + &:hover { + color: var(--color-primary); + } + &:active { color: saturation(--color-primary-h, --color-primary-s, --color-primary-l, -(30%)); } diff --git a/packages/design-system/theme/src/input.scss b/packages/design-system/theme/src/input.scss index 1264833936c03..ed909a7d85256 100644 --- a/packages/design-system/theme/src/input.scss +++ b/packages/design-system/theme/src/input.scss @@ -116,7 +116,7 @@ height: var.$input-height; line-height: var.$input-height; outline: none; - padding: 0 var(--spacing-xs); + padding: 0 0 0 var(--spacing-2xs); transition: var.$border-transition-base; width: 100%; @@ -133,7 +133,7 @@ @include mixins.e(suffix) { position: absolute; height: 100%; - right: 10px; + right: var(--spacing-2xs); top: 0; text-align: center; color: var(--color-text-light); diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index a083eb5a766f8..46d5fa4920979 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -94,6 +94,7 @@ "vue-template-compiler": "~2.6.11", "vue-typed-mixins": "^0.2.0", "vue2-touch-events": "^3.2.1", + "fast-json-stable-stringify": "^2.1.0", "vuex": "^3.1.1" } } diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 5e5be879481b8..8721619809de9 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -1,4 +1,3 @@ - import { GenericValue, IConnections, @@ -19,6 +18,11 @@ import { IWorkflowSettings as IWorkflowSettingsWorkflow, WorkflowExecuteMode, PublicInstalledPackage, + IResourceLocatorResult, + INodeTypeNameVersion, + ILoadOptions, + INodeCredentials, + INodeListSearchItems, } from 'n8n-workflow'; import { FAKE_DOOR_FEATURES } from './constants'; @@ -1079,3 +1083,18 @@ export interface ITab { align?: 'right'; tooltip?: string; } + +export interface IResourceLocatorReqParams { + nodeTypeAndVersion: INodeTypeNameVersion; + path: string; + methodName?: string; + searchList?: ILoadOptions; + currentNodeParameters: INodeParameters; + credentials?: INodeCredentials; + filter?: string; + paginationToken?: unknown; +} + +export interface IResourceLocatorResultExpanded extends INodeListSearchItems { + linkAlt?: string; +} diff --git a/packages/editor-ui/src/api/nodeTypes.ts b/packages/editor-ui/src/api/nodeTypes.ts index 2328db1a70f7b..b6ebfe8f2bf4a 100644 --- a/packages/editor-ui/src/api/nodeTypes.ts +++ b/packages/editor-ui/src/api/nodeTypes.ts @@ -1,11 +1,14 @@ import { makeRestApiRequest } from './helpers'; import type { INodeTranslationHeaders, + IResourceLocatorReqParams, IRestApiContext, } from '@/Interface'; import type { + IDataObject, ILoadOptions, INodeCredentials, + INodeListSearchResult, INodeParameters, INodePropertyOptions, INodeTypeDescription, @@ -45,3 +48,11 @@ export async function getNodeParameterOptions( ): Promise { return makeRestApiRequest(context, 'GET', '/node-parameter-options', sendData); } + +export async function getResourceLocatorResults( + context: IRestApiContext, + sendData: IResourceLocatorReqParams, +): Promise { + return makeRestApiRequest(context, 'GET', '/nodes-list-search', sendData as unknown as IDataObject); +} + diff --git a/packages/editor-ui/src/components/BreakpointsObserver.vue b/packages/editor-ui/src/components/BreakpointsObserver.vue index b34a54799d967..bad6647408951 100644 --- a/packages/editor-ui/src/components/BreakpointsObserver.vue +++ b/packages/editor-ui/src/components/BreakpointsObserver.vue @@ -23,8 +23,9 @@ import { import mixins from "vue-typed-mixins"; import { genericHelpers } from "@/components/mixins/genericHelpers"; +import { debounceHelper } from "./mixins/debounce"; -export default mixins(genericHelpers).extend({ +export default mixins(genericHelpers, debounceHelper).extend({ name: "BreakpointsObserver", props: [ "valueXS", @@ -98,4 +99,4 @@ export default mixins(genericHelpers).extend({ }, }, }); - \ No newline at end of file + diff --git a/packages/editor-ui/src/components/CredentialCard.vue b/packages/editor-ui/src/components/CredentialCard.vue index 51f9356ac0f75..d708da8a05d52 100644 --- a/packages/editor-ui/src/components/CredentialCard.vue +++ b/packages/editor-ui/src/components/CredentialCard.vue @@ -112,7 +112,7 @@ export default mixins( }, methods: { async onClick() { - this.$store.dispatch('ui/openExisitngCredential', { id: this.data.id}); + this.$store.dispatch('ui/openExistingCredential', { id: this.data.id}); }, async onAction(action: string) { if (action === CREDENTIAL_LIST_ITEM_ACTIONS.OPEN) { diff --git a/packages/editor-ui/src/components/CredentialsList.vue b/packages/editor-ui/src/components/CredentialsList.vue index 6c4f0ac713276..4c9a00f57cbd0 100644 --- a/packages/editor-ui/src/components/CredentialsList.vue +++ b/packages/editor-ui/src/components/CredentialsList.vue @@ -107,7 +107,7 @@ export default mixins( }, editCredential (credential: ICredentialsResponse) { - this.$store.dispatch('ui/openExisitngCredential', { id: credential.id}); + this.$store.dispatch('ui/openExistingCredential', { id: credential.id}); this.$telemetry.track('User opened Credential modal', { credential_type: credential.type, source: 'primary_menu', new_credential: false, workflow_id: this.$store.getters.workflowId }); }, diff --git a/packages/editor-ui/src/components/DraggableTarget.vue b/packages/editor-ui/src/components/DraggableTarget.vue index 1f1e13fd1bc2c..72b49dd9c6888 100644 --- a/packages/editor-ui/src/components/DraggableTarget.vue +++ b/packages/editor-ui/src/components/DraggableTarget.vue @@ -59,7 +59,7 @@ export default Vue.extend({ this.hovering = e.clientX >= dim.left && e.clientX <= dim.right && e.clientY >= dim.top && e.clientY <= dim.bottom; - if (this.sticky && this.hovering) { + if (!this.disabled && this.sticky && this.hovering) { this.$store.commit('ui/setDraggableStickyPos', [dim.left + this.stickyOffset, dim.top + this.stickyOffset]); } } diff --git a/packages/editor-ui/src/components/ExpressionEdit.vue b/packages/editor-ui/src/components/ExpressionEdit.vue index 14f3f99d7e8d3..5c159079fb22a 100644 --- a/packages/editor-ui/src/components/ExpressionEdit.vue +++ b/packages/editor-ui/src/components/ExpressionEdit.vue @@ -53,10 +53,12 @@ import { genericHelpers } from '@/components/mixins/genericHelpers'; import mixins from 'vue-typed-mixins'; import { hasExpressionMapping } from './helpers'; +import { debounceHelper } from './mixins/debounce'; export default mixins( externalHooks, genericHelpers, + debounceHelper, ).extend({ name: 'ExpressionEdit', props: [ diff --git a/packages/editor-ui/src/components/MainSidebar.vue b/packages/editor-ui/src/components/MainSidebar.vue index 95f7ce60a998b..be1e11d1a350a 100644 --- a/packages/editor-ui/src/components/MainSidebar.vue +++ b/packages/editor-ui/src/components/MainSidebar.vue @@ -636,7 +636,7 @@ export default mixins( height: 35px; line-height: 35px; color: $--custom-dialog-text-color; - --menu-item-hover-fill: var(--color-primary-tint-3); + --menu-item-hover-fill: var(--color-background-base); .item-title { position: absolute; @@ -656,7 +656,7 @@ export default mixins( .el-menu { border: none; font-size: 14px; - --menu-item-hover-fill: var(--color-primary-tint-3); + --menu-item-hover-fill: var(--color-background-base); .el-menu--collapse { width: 75px; diff --git a/packages/editor-ui/src/components/NodeCredentials.vue b/packages/editor-ui/src/components/NodeCredentials.vue index cb6302b211698..84b6bd9d44f07 100644 --- a/packages/editor-ui/src/components/NodeCredentials.vue +++ b/packages/editor-ui/src/components/NodeCredentials.vue @@ -314,7 +314,7 @@ export default mixins( editCredential(credentialType: string): void { const { id } = this.node.credentials[credentialType]; - this.$store.dispatch('ui/openExisitngCredential', { id }); + this.$store.dispatch('ui/openExistingCredential', { id }); this.$telemetry.track('User opened Credential modal', { credential_type: credentialType, source: 'node', new_credential: false, workflow_id: this.$store.getters.workflowId }); diff --git a/packages/editor-ui/src/components/ParameterInput.vue b/packages/editor-ui/src/components/ParameterInput.vue index 590380edda0b6..d741a17995415 100644 --- a/packages/editor-ui/src/components/ParameterInput.vue +++ b/packages/editor-ui/src/components/ParameterInput.vue @@ -2,7 +2,7 @@
+ -
- +
@@ -274,7 +291,6 @@ import { import { NodeHelpers, NodeParameterValue, - IHttpRequestOptions, ILoadOptions, INodeParameters, INodePropertyOptions, @@ -288,6 +304,7 @@ import NodeCredentials from '@/components/NodeCredentials.vue'; import ScopesNotice from '@/components/ScopesNotice.vue'; import ParameterOptions from '@/components/ParameterOptions.vue'; import ParameterIssues from '@/components/ParameterIssues.vue'; +import ResourceLocator from '@/components/ResourceLocator/ResourceLocator.vue'; // @ts-ignore import PrismEditor from 'vue-prism-editor'; import TextEdit from '@/components/TextEdit.vue'; @@ -299,7 +316,8 @@ import { workflowHelpers } from '@/components/mixins/workflowHelpers'; import mixins from 'vue-typed-mixins'; import { CUSTOM_API_CALL_KEY } from '@/constants'; import { mapGetters } from 'vuex'; -import { hasExpressionMapping } from './helpers'; +import { hasExpressionMapping, isValueExpression } from './helpers'; +import { isResourceLocatorValue } from '@/typeGuards'; export default mixins( externalHooks, @@ -308,7 +326,7 @@ export default mixins( workflowHelpers, ) .extend({ - name: 'ParameterInput', + name: 'parameter-input', components: { CodeEdit, ExpressionEdit, @@ -318,6 +336,7 @@ export default mixins( ScopesNotice, ParameterOptions, ParameterIssues, + ResourceLocator, TextEdit, }, props: [ @@ -395,6 +414,9 @@ export default mixins( }, computed: { ...mapGetters('credentials', ['allCredentialTypes']), + isValueExpression(): boolean { + return isValueExpression(this.parameter, this.value); + }, areExpressionsDisabled(): boolean { return this.$store.getters['ui/areExpressionsDisabled']; }, @@ -459,7 +481,7 @@ export default mixins( let returnValue; if (this.isValueExpression === false) { - returnValue = this.value; + returnValue = this.isResourceLocatorParameter ? (this.value ? this.value.value: '') : this.value; } else { returnValue = this.expressionValueComputed; } @@ -518,7 +540,7 @@ export default mixins( let computedValue: NodeParameterValue; try { - computedValue = this.resolveExpression(this.value) as NodeParameterValue; + computedValue = this.resolveExpression(this.value.value || this.value) as NodeParameterValue; } catch (error) { computedValue = `[${this.$locale.baseText('parameterInput.error')}}: ${error.message}]`; } @@ -610,15 +632,6 @@ export default mixins( isEditor (): boolean { return ['code', 'json'].includes(this.editorType); }, - isValueExpression () { - if (this.parameter.noDataExpression === true) { - return false; - } - if (typeof this.value === 'string' && this.value.charAt(0) === '=') { - return true; - } - return false; - }, editorType (): string { return this.getArgument('editor') as string; }, @@ -659,7 +672,7 @@ export default mixins( const styles = { width: '100%', }; - if (this.parameter.type === 'credentialsSelect') { + if (this.parameter.type === 'credentialsSelect' || this.isResourceLocatorParameter) { return styles; } if (this.getIssues.length) { @@ -683,6 +696,9 @@ export default mixins( workflow (): Workflow { return this.getCurrentWorkflow(); }, + isResourceLocatorParameter (): boolean { + return this.parameter.type === 'resourceLocator'; + }, }, methods: { isRemoteParameterOption(option: INodePropertyOptions) { @@ -730,9 +746,9 @@ export default mixins( this.remoteParameterOptions.length = 0; // Get the resolved parameter values of the current node - const currentNodeParameters = this.$store.getters.activeNode.parameters; try { + const currentNodeParameters = (this.$store.getters.activeNode as INodeUi).parameters; const resolvedNodeParameters = this.resolveParameter(currentNodeParameters) as INodeParameters; const loadOptionsMethod = this.getArgument('loadOptionsMethod') as string | undefined; const loadOptions = this.getArgument('loadOptions') as ILoadOptions | undefined; @@ -803,7 +819,8 @@ export default mixins( return this.parameter.typeOptions[argumentName]; }, expressionUpdated (value: string) { - this.valueChanged(value); + const val = this.isResourceLocatorParameter ? { value, mode: this.value.mode } : value; + this.valueChanged(val); }, openExpressionEdit() { if (this.areExpressionsDisabled) { @@ -819,6 +836,9 @@ export default mixins( onBlur () { this.$emit('blur'); }, + onResourceLocatorDrop(data: string) { + this.$emit('drop', data); + }, setFocus () { if (this.isValueExpression) { this.expressionEditDialogVisible = true; @@ -844,7 +864,7 @@ export default mixins( // Set focus on field setTimeout(() => { // @ts-ignore - if (this.$refs.inputField) { + if (this.$refs.inputField && this.$refs.inputField.$el) { // @ts-ignore this.$refs.inputField.focus(); } @@ -871,7 +891,7 @@ export default mixins( this.$emit('textInput', parameterData); }, - valueChanged (value: string[] | string | number | boolean | Date | null) { + valueChanged (value: string[] | string | number | boolean | Date | {} | null) { if (this.parameter.name === 'nodeCredentialType') { this.activeCredentialType = value as string; } @@ -916,9 +936,14 @@ export default mixins( this.expressionEditDialogVisible = true; } else if (command === 'addExpression') { if (this.parameter.type === 'number' || this.parameter.type === 'boolean') { - this.valueChanged(`={{${this.value}}}`); - } - else { + this.valueChanged({ value: `={{${this.value}}}`, mode: this.value.mode }); + } else if (this.isResourceLocatorParameter) { + if (isResourceLocatorValue(this.value)) { + this.valueChanged({ value: `=${this.value.value}`, mode: this.value.mode }); + } else { + this.valueChanged({ value: `=${this.value}`, mode: '' }); + } + } else { this.valueChanged(`=${this.value}`); } @@ -934,8 +959,18 @@ export default mixins( .filter((value) => (this.parameterOptions || []).find((option) => option.value === value)); } - this.valueChanged(typeof value !== 'undefined' ? value : null); + if (this.isResourceLocatorParameter) { + this.valueChanged({ value, mode: this.value.mode }); + } else { + this.valueChanged(typeof value !== 'undefined' ? value : null); + } } else if (command === 'refreshOptions') { + if (this.isResourceLocatorParameter) { + const resourceLocator = this.$refs.resourceLocator; + if (resourceLocator) { + (resourceLocator as Vue).$emit('refreshList'); + } + } this.loadRemoteParameterOptions(); } diff --git a/packages/editor-ui/src/components/ParameterInputExpanded.vue b/packages/editor-ui/src/components/ParameterInputExpanded.vue index 2f837d9cc4148..efa4c72d1c3d4 100644 --- a/packages/editor-ui/src/components/ParameterInputExpanded.vue +++ b/packages/editor-ui/src/components/ParameterInputExpanded.vue @@ -12,6 +12,7 @@ :value="value" :isReadOnly="false" :showOptions="true" + :isValueExpression="isValueExpression" @optionSelected="optionSelected" @menu-expanded="onMenuExpanded" /> @@ -29,6 +30,7 @@ :errorHighlight="showRequiredErrors" :isForCredential="true" :eventSource="eventSource" + :isValueExpression="isValueExpression" @focus="onFocus" @blur="onBlur" @textInput="valueChanged" @@ -53,6 +55,8 @@ import ParameterInput from './ParameterInput.vue'; import ParameterOptions from './ParameterOptions.vue'; import InputHint from './ParameterInputHint.vue'; import Vue from 'vue'; +import { isValueExpression } from './helpers'; +import { INodeParameterResourceLocator, INodeProperties } from 'n8n-workflow'; export default Vue.extend({ name: 'ParameterInputExpanded', @@ -63,6 +67,7 @@ export default Vue.extend({ }, props: { parameter: { + type: Object as () => INodeProperties, }, value: { }, @@ -101,6 +106,9 @@ export default Vue.extend({ return false; }, + isValueExpression (): boolean { + return isValueExpression(this.parameter, this.value as string | INodeParameterResourceLocator); + }, }, methods: { onFocus() { diff --git a/packages/editor-ui/src/components/ParameterInputFull.vue b/packages/editor-ui/src/components/ParameterInputFull.vue index 959ec387b3a3a..a715068381edd 100644 --- a/packages/editor-ui/src/components/ParameterInputFull.vue +++ b/packages/editor-ui/src/components/ParameterInputFull.vue @@ -13,12 +13,19 @@ :value="value" :isReadOnly="isReadOnly" :showOptions="displayOptions" + :showExpressionSelector="showExpressionSelector" @optionSelected="optionSelected" @menu-expanded="onMenuExpanded" /> @@ -57,12 +65,15 @@ import mixins from 'vue-typed-mixins'; import { showMessage } from './mixins/showMessage'; import { LOCAL_STORAGE_MAPPING_FLAG } from '@/constants'; import { hasExpressionMapping } from './helpers'; +import { hasOnlyListMode } from './ResourceLocator/helpers'; +import { INodePropertyMode } from 'n8n-workflow'; +import { isResourceLocatorValue } from '@/typeGuards'; export default mixins( showMessage, ) .extend({ - name: 'ParameterInputFull', + name: 'parameter-input-full', components: { ParameterInput, InputHint, @@ -88,6 +99,15 @@ export default mixins( node (): INodeUi | null { return this.$store.getters.activeNode; }, + isResourceLocator (): boolean { + return this.parameter.type === 'resourceLocator'; + }, + isDropDisabled (): boolean { + return this.parameter.noDataExpression || this.isReadOnly || this.isResourceLocator; + }, + showExpressionSelector (): boolean { + return this.isResourceLocator ? !hasOnlyListMode(this.parameter): true; + }, }, methods: { onFocus() { @@ -117,7 +137,7 @@ export default mixins( this.forceShowExpression = true; setTimeout(() => { if (this.node) { - const prevValue = this.value; + const prevValue = this.isResourceLocator ? this.value.value : this.value; let updatedValue: string; if (typeof prevValue === 'string' && prevValue.startsWith('=') && prevValue.length > 1) { updatedValue = `${prevValue} ${data}`; @@ -126,11 +146,43 @@ export default mixins( updatedValue = `=${data}`; } - const parameterData = { - node: this.node.name, - name: this.path, - value: updatedValue, - }; + + let parameterData; + if (this.isResourceLocator) { + if (!isResourceLocatorValue(this.value)) { + parameterData = { + node: this.node.name, + name: this.path, + value: { value: updatedValue, mode: '' }, + }; + } + else if (this.value.mode === 'list' && this.parameter.modes && this.parameter.modes.length > 1) { + let mode = this.parameter.modes.find((mode: INodePropertyMode) => mode.name === 'id') || null; + if (!mode) { + mode = this.parameter.modes.filter((mode: INodePropertyMode) => mode.name !== 'list')[0]; + } + + parameterData = { + node: this.node.name, + name: this.path, + value: { value: updatedValue, mode: mode ? mode.name : '' }, + }; + } + else { + parameterData = { + node: this.node.name, + name: this.path, + value: { value: updatedValue, mode: this.value.mode }, + }; + } + + } else { + parameterData = { + node: this.node.name, + name: this.path, + value: updatedValue, + }; + } this.$emit('valueChanged', parameterData); diff --git a/packages/editor-ui/src/components/ParameterOptions.vue b/packages/editor-ui/src/components/ParameterOptions.vue index d107528eb1425..e68464db0ee96 100644 --- a/packages/editor-ui/src/components/ParameterOptions.vue +++ b/packages/editor-ui/src/components/ParameterOptions.vue @@ -11,7 +11,7 @@ @visible-change="onMenuToggle" /> + + diff --git a/packages/editor-ui/src/components/ResourceLocator/ResourceLocatorDropdown.vue b/packages/editor-ui/src/components/ResourceLocator/ResourceLocatorDropdown.vue new file mode 100644 index 0000000000000..b0c2bc1bfbe67 --- /dev/null +++ b/packages/editor-ui/src/components/ResourceLocator/ResourceLocatorDropdown.vue @@ -0,0 +1,327 @@ + + + + + diff --git a/packages/editor-ui/src/components/ResourceLocator/helpers.ts b/packages/editor-ui/src/components/ResourceLocator/helpers.ts new file mode 100644 index 0000000000000..42d3007236a6e --- /dev/null +++ b/packages/editor-ui/src/components/ResourceLocator/helpers.ts @@ -0,0 +1,7 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const hasOnlyListMode = (parameter: INodeProperties) : boolean => { + return parameter.modes !== undefined && parameter.modes.length === 1 && parameter.modes[0].name === 'list'; +}; diff --git a/packages/editor-ui/src/components/helpers.ts b/packages/editor-ui/src/components/helpers.ts index 84f059a350b3e..fae6de2ffb3ea 100644 --- a/packages/editor-ui/src/components/helpers.ts +++ b/packages/editor-ui/src/components/helpers.ts @@ -1,9 +1,11 @@ import { CORE_NODES_CATEGORY, ERROR_TRIGGER_NODE_TYPE, MAPPING_PARAMS, TEMPLATES_NODES_FILTER } from '@/constants'; import { INodeUi, ITemplatesNode } from '@/Interface'; +import { isResourceLocatorValue } from '@/typeGuards'; import dateformat from 'dateformat'; -import {IDataObject, INodeTypeDescription} from 'n8n-workflow'; +import {IDataObject, INodeProperties, INodeTypeDescription, NodeParameterValueType} from 'n8n-workflow'; -const KEYWORDS_TO_FILTER = ['API', 'OAuth1', 'OAuth2']; +const CRED_KEYWORDS_TO_FILTER = ['API', 'OAuth1', 'OAuth2']; +const NODE_KEYWORDS_TO_FILTER = ['Trigger']; const SI_SYMBOL = ['', 'k', 'M', 'G', 'T', 'P', 'E']; const COMMUNITY_PACKAGE_NAME_REGEX = /(@\w+\/)?n8n-nodes-(?!base\b)\b\w+/g; @@ -29,7 +31,11 @@ export function convertToHumanReadableDate (epochTime: number) { } export function getAppNameFromCredType(name: string) { - return name.split(' ').filter((word) => !KEYWORDS_TO_FILTER.includes(word)).join(' '); + return name.split(' ').filter((word) => !CRED_KEYWORDS_TO_FILTER.includes(word)).join(' '); +} + +export function getAppNameFromNodeName(name: string) { + return name.split(' ').filter((word) => !NODE_KEYWORDS_TO_FILTER.includes(word)).join(' '); } export function getStyleTokenValue(name: string): string { @@ -99,3 +105,16 @@ export function shorten(s: string, limit: number, keep: number) { export function hasExpressionMapping(value: unknown) { return typeof value === 'string' && !!MAPPING_PARAMS.find((param) => value.includes(param)); } + +export function isValueExpression (parameter: INodeProperties, paramValue: NodeParameterValueType): boolean { + if (parameter.noDataExpression === true) { + return false; + } + if (typeof paramValue === 'string' && paramValue.charAt(0) === '=') { + return true; + } + if (isResourceLocatorValue(paramValue) && paramValue.value && paramValue.value.toString().charAt(0) === '=') { + return true; + } + return false; +} diff --git a/packages/editor-ui/src/components/mixins/debounce.ts b/packages/editor-ui/src/components/mixins/debounce.ts new file mode 100644 index 0000000000000..fe11b341c3d4d --- /dev/null +++ b/packages/editor-ui/src/components/mixins/debounce.ts @@ -0,0 +1,24 @@ +import { debounce } from 'lodash'; +import Vue from 'vue'; + +export const debounceHelper = Vue.extend({ + data () { + return { + debouncedFunctions: [] as any[], // tslint:disable-line:no-any + }; + }, + methods: { + async callDebounced (...inputParameters: any[]): Promise { // tslint:disable-line:no-any + const functionName = inputParameters.shift() as string; + const { trailing, debounceTime } = inputParameters.shift(); + + // @ts-ignore + if (this.debouncedFunctions[functionName] === undefined) { + // @ts-ignore + this.debouncedFunctions[functionName] = debounce(this[functionName], debounceTime, trailing ? { trailing } : { leading: true } ); + } + // @ts-ignore + await this.debouncedFunctions[functionName].apply(this, inputParameters); + }, + }, +}); diff --git a/packages/editor-ui/src/components/mixins/genericHelpers.ts b/packages/editor-ui/src/components/mixins/genericHelpers.ts index ba56603786401..1306532152563 100644 --- a/packages/editor-ui/src/components/mixins/genericHelpers.ts +++ b/packages/editor-ui/src/components/mixins/genericHelpers.ts @@ -1,6 +1,5 @@ import { showMessage } from '@/components/mixins/showMessage'; import { VIEWS } from '@/constants'; -import { debounce } from 'lodash'; import mixins from 'vue-typed-mixins'; @@ -8,7 +7,6 @@ export const genericHelpers = mixins(showMessage).extend({ data () { return { loadingService: null as any | null, // tslint:disable-line:no-any - debouncedFunctions: [] as any[], // tslint:disable-line:no-any }; }, computed: { @@ -71,18 +69,5 @@ export const genericHelpers = mixins(showMessage).extend({ this.loadingService = null; } }, - - async callDebounced (...inputParameters: any[]): Promise { // tslint:disable-line:no-any - const functionName = inputParameters.shift() as string; - const { trailing, debounceTime } = inputParameters.shift(); - - // @ts-ignore - if (this.debouncedFunctions[functionName] === undefined) { - // @ts-ignore - this.debouncedFunctions[functionName] = debounce(this[functionName], debounceTime, trailing ? { trailing } : { leading: true } ); - } - // @ts-ignore - await this.debouncedFunctions[functionName].apply(this, inputParameters); - }, }, }); diff --git a/packages/editor-ui/src/modules/nodeTypes.ts b/packages/editor-ui/src/modules/nodeTypes.ts index 79f4a4838db88..32c628ab0c90c 100644 --- a/packages/editor-ui/src/modules/nodeTypes.ts +++ b/packages/editor-ui/src/modules/nodeTypes.ts @@ -3,6 +3,7 @@ import { ActionContext, Module } from 'vuex'; import type { ILoadOptions, INodeCredentials, + INodeListSearchResult, INodeParameters, INodeTypeDescription, INodeTypeNameVersion, @@ -15,9 +16,10 @@ import { getNodesInformation, getNodeTranslationHeaders, getNodeTypes, + getResourceLocatorResults, } from '@/api/nodeTypes'; import { omit } from '@/utils'; -import type { IRootState, INodeTypesState } from '../Interface'; +import type { IRootState, INodeTypesState, IResourceLocatorReqParams } from '../Interface'; const module: Module = { namespaced: true, @@ -142,6 +144,12 @@ const module: Module = { ) { return getNodeParameterOptions(context.rootGetters.getRestApiContext, sendData); }, + async getResourceLocatorResults( + context: ActionContext, + sendData: IResourceLocatorReqParams, + ): Promise { + return getResourceLocatorResults(context.rootGetters.getRestApiContext, sendData); + }, }, }; diff --git a/packages/editor-ui/src/modules/ui.ts b/packages/editor-ui/src/modules/ui.ts index c714cfb63df44..1c2f226dff59b 100644 --- a/packages/editor-ui/src/modules/ui.ts +++ b/packages/editor-ui/src/modules/ui.ts @@ -304,7 +304,7 @@ const module: Module = { context.commit('setActiveId', { name: DELETE_USER_MODAL_KEY, id }); context.commit('openModal', DELETE_USER_MODAL_KEY); }, - openExisitngCredential: async (context: ActionContext, { id }: {id: string}) => { + openExistingCredential: async (context: ActionContext, { id }: {id: string}) => { context.commit('setActiveId', { name: CREDENTIAL_EDIT_MODAL_KEY, id }); context.commit('setMode', { name: CREDENTIAL_EDIT_MODAL_KEY, mode: 'edit' }); context.commit('openModal', CREDENTIAL_EDIT_MODAL_KEY); diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 5305f2ebeb3e9..f096dce93141b 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -729,6 +729,24 @@ "pushConnectionTracker.connectionLost": "Connection lost", "pushConnection.pollingNode.dataNotFound": "No {service} data found", "pushConnection.pollingNode.dataNotFound.message": "We didn’t find any data in {service} to simulate an event. Please create one in {service} and try again.", + "resourceLocator.id.placeholder": "Enter ID...", + "resourceLocator.mode.id": "By ID", + "resourceLocator.mode.url": "By URL", + "resourceLocator.mode.list": "From list", + "resourceLocator.mode.list.disabled.title": "Change to Fixed mode to choose From List", + "resourceLocator.mode.list.error.title": "Could not load list", + "resourceLocator.mode.list.error.description.part1": "Check that your", + "resourceLocator.mode.list.error.description.part2": "credential", + "resourceLocator.mode.list.error.description.part3": "is set up correctly", + "resourceLocator.mode.list.noResults": "No results", + "resourceLocator.mode.list.openUrl": "Open URL", + "resourceLocator.mode.list.placeholder": "Choose...", + "resourceLocator.mode.list.searchRequired": "Enter a search term to show results", + "resourceLocator.modeSelector.placeholder": "Mode...", + "resourceLocator.openSpecificResource": "Open {entity} in {appName}", + "resourceLocator.openResource": "Open in {appName}", + "resourceLocator.search.placeholder": "Search...", + "resourceLocator.url.placeholder": "Enter URL...", "runData.emptyItemHint": "This is an item, but it's empty.", "runData.emptyArray": "[empty array]", "runData.emptyString": "[empty]", diff --git a/packages/editor-ui/src/plugins/icons.ts b/packages/editor-ui/src/plugins/icons.ts index eac094a15c0c4..7e9487dd4db68 100644 --- a/packages/editor-ui/src/plugins/icons.ts +++ b/packages/editor-ui/src/plugins/icons.ts @@ -38,6 +38,7 @@ import { faEye, faExclamationTriangle, faExpand, + faExpandAlt, faExternalLinkAlt, faExchangeAlt, faFile, @@ -154,6 +155,7 @@ addIcon(faEnvelope); addIcon(faEye); addIcon(faExclamationTriangle); addIcon(faExpand); +addIcon(faExpandAlt); addIcon(faExternalLinkAlt); addIcon(faExchangeAlt); addIcon(faFile); diff --git a/packages/editor-ui/src/typeGuards.ts b/packages/editor-ui/src/typeGuards.ts new file mode 100644 index 0000000000000..2818cc871bc34 --- /dev/null +++ b/packages/editor-ui/src/typeGuards.ts @@ -0,0 +1,5 @@ +import { INodeParameterResourceLocator } from "n8n-workflow"; + +export function isResourceLocatorValue(value: unknown): value is INodeParameterResourceLocator { + return Boolean(typeof value === 'object' && value && 'mode' in value && 'mode' in value); +} diff --git a/packages/editor-ui/src/views/CredentialsView.vue b/packages/editor-ui/src/views/CredentialsView.vue index f807260c9352e..f0e1fb4aae4e6 100644 --- a/packages/editor-ui/src/views/CredentialsView.vue +++ b/packages/editor-ui/src/views/CredentialsView.vue @@ -255,12 +255,11 @@ import {ICredentialType} from "n8n-workflow"; import {EnterpriseEditionFeature} from "@/constants"; import TemplateCard from "@/components/TemplateCard.vue"; import Vue from "vue"; -import { debounce } from 'lodash'; -import {genericHelpers} from "@/components/mixins/genericHelpers"; +import { debounceHelper } from '@/components/mixins/debounce'; export default mixins( showMessage, - genericHelpers, + debounceHelper, ).extend({ name: 'SettingsPersonalView', components: { diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index 550bc22a576ac..d0ae4aa92162d 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -239,6 +239,7 @@ import '../plugins/PlusEndpointType'; import { getAccountAge } from '@/modules/userHelpers'; import { IUser } from 'n8n-design-system'; import {dataPinningEventBus} from "@/event-bus/data-pinning-event-bus"; +import { debounceHelper } from '@/components/mixins/debounce'; interface AddNodeOptions { position?: XYPosition; @@ -258,6 +259,7 @@ export default mixins( workflowHelpers, workflowRun, newVersions, + debounceHelper, ) .extend({ name: 'NodeView', diff --git a/packages/editor-ui/src/views/TemplatesSearchView.vue b/packages/editor-ui/src/views/TemplatesSearchView.vue index ba2b24f40fb72..34f60b57a6edd 100644 --- a/packages/editor-ui/src/views/TemplatesSearchView.vue +++ b/packages/editor-ui/src/views/TemplatesSearchView.vue @@ -85,6 +85,7 @@ import { mapGetters } from 'vuex'; import { IDataObject } from 'n8n-workflow'; import { setPageTitle } from '@/components/helpers'; import { VIEWS } from '@/constants'; +import { debounceHelper } from '@/components/mixins/debounce'; interface ISearchEvent { search_string: string; @@ -94,7 +95,7 @@ interface ISearchEvent { wf_template_repo_session_id: number; } -export default mixins(genericHelpers).extend({ +export default mixins(genericHelpers, debounceHelper).extend({ name: 'TemplatesSearchView', components: { CollectionsCarousel, diff --git a/packages/nodes-base/nodes/Airtable/Airtable.node.ts b/packages/nodes-base/nodes/Airtable/Airtable.node.ts index 0dc96e74ca167..4ae888f4e112d 100644 --- a/packages/nodes-base/nodes/Airtable/Airtable.node.ts +++ b/packages/nodes-base/nodes/Airtable/Airtable.node.ts @@ -2,7 +2,9 @@ import { IExecuteFunctions } from 'n8n-core'; import { IDataObject, + ILoadOptionsFunctions, INodeExecutionData, + INodeListSearchResult, INodeType, INodeTypeDescription, NodeOperationError, @@ -10,6 +12,17 @@ import { import { apiRequest, apiRequestAllItems, downloadRecordAttachments } from './GenericFunctions'; +interface AirtableBase { + id: string; + name: string; +} + +interface AirtableTable { + id: string; + name: string; + description: string; +} + export class Airtable implements INodeType { description: INodeTypeDescription = { displayName: 'Airtable', @@ -73,22 +86,94 @@ export class Airtable implements INodeType { // ---------------------------------- // All // ---------------------------------- + { - displayName: 'Base ID', + displayName: 'Base', name: 'application', - type: 'string', - default: '', + type: 'resourceLocator', + default: { mode: 'url', value: '' }, required: true, - description: 'The ID of the base to access', + description: 'The Airtable Base in which to operate on', + modes: [ + { + displayName: 'By URL', + name: 'url', + type: 'string', + placeholder: 'https://airtable.com/app12DiScdfes/tblAAAAAAAAAAAAA/viwHdfasdfeieg5p', + validation: [ + { + type: 'regex', + properties: { + regex: 'https://airtable.com/([a-zA-Z0-9]{2,})/.*', + errorMessage: 'Not a valid Airtable Base URL', + }, + }, + ], + extractValue: { + type: 'regex', + regex: 'https://airtable.com/([a-zA-Z0-9]{2,})', + }, + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: '[a-zA-Z0-9]{2,}', + errorMessage: 'Not a valid Airtable Base ID', + }, + }, + ], + placeholder: 'appD3dfaeidke', + url: '=https://airtable.com/{{$value}}', + }, + ], }, { - displayName: 'Table ID', + displayName: 'Table', name: 'table', - type: 'string', - default: '', - placeholder: 'Stories', + type: 'resourceLocator', + default: { mode: 'url', value: '' }, required: true, - description: 'The ID of the table to access', + modes: [ + { + displayName: 'By URL', + name: 'url', + type: 'string', + placeholder: 'https://airtable.com/app12DiScdfes/tblAAAAAAAAAAAAA/viwHdfasdfeieg5p', + validation: [ + { + type: 'regex', + properties: { + regex: 'https://airtable.com/[a-zA-Z0-9]{2,}/([a-zA-Z0-9]{2,})/.*', + errorMessage: 'Not a valid Airtable Table URL', + }, + }, + ], + extractValue: { + type: 'regex', + regex: 'https://airtable.com/[a-zA-Z0-9]{2,}/([a-zA-Z0-9]{2,})', + }, + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: '[a-zA-Z0-9]{2,}', + errorMessage: 'Not a valid Airtable Table ID', + }, + }, + ], + placeholder: 'tbl3dirwqeidke', + }, + ], }, // ---------------------------------- @@ -423,8 +508,15 @@ export class Airtable implements INodeType { const operation = this.getNodeParameter('operation', 0) as string; - const application = this.getNodeParameter('application', 0) as string; - const table = encodeURI(this.getNodeParameter('table', 0) as string); + const application = this.getNodeParameter('application', 0, undefined, { + extractValue: true, + }) as string; + + const table = encodeURI( + this.getNodeParameter('table', 0, undefined, { + extractValue: true, + }) as string, + ); let returnAll = false; let endpoint = ''; @@ -493,7 +585,7 @@ export class Airtable implements INodeType { } } catch (error) { if (this.continueOnFail()) { - returnData.push({json: { error: error.message }}); + returnData.push({ json: { error: error.message } }); continue; } throw error; @@ -538,7 +630,7 @@ export class Airtable implements INodeType { } } catch (error) { if (this.continueOnFail()) { - returnData.push({json:{ error: error.message }}); + returnData.push({ json: { error: error.message } }); continue; } throw error; @@ -589,14 +681,13 @@ export class Airtable implements INodeType { // We can return from here return [ - this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(returnData), - { itemData: { item: 0 } }, - ), + this.helpers.constructExecutionMetaData(this.helpers.returnJsonArray(returnData), { + itemData: { item: 0 }, + }), ]; } catch (error) { if (this.continueOnFail()) { - returnData.push({json:{ error: error.message }}); + returnData.push({ json: { error: error.message } }); } else { throw error; } @@ -631,7 +722,7 @@ export class Airtable implements INodeType { returnData.push(...executionData); } catch (error) { if (this.continueOnFail()) { - returnData.push({json:{ error: error.message }}); + returnData.push({ json: { error: error.message } }); continue; } throw error; @@ -718,7 +809,7 @@ export class Airtable implements INodeType { } } catch (error) { if (this.continueOnFail()) { - returnData.push({json:{ error: error.message }}); + returnData.push({ json: { error: error.message } }); continue; } throw error; diff --git a/packages/nodes-base/nodes/Google/Drive/GoogleDrive.node.ts b/packages/nodes-base/nodes/Google/Drive/GoogleDrive.node.ts index e24d5c6bc719d..0d5eb544f40fa 100644 --- a/packages/nodes-base/nodes/Google/Drive/GoogleDrive.node.ts +++ b/packages/nodes-base/nodes/Google/Drive/GoogleDrive.node.ts @@ -2,7 +2,9 @@ import { IExecuteFunctions } from 'n8n-core'; import { IDataObject, + ILoadOptionsFunctions, INodeExecutionData, + INodeListSearchResult, INodeType, INodeTypeDescription, NodeOperationError, @@ -12,6 +14,18 @@ import { googleApiRequest, googleApiRequestAllItems } from './GenericFunctions'; import { v4 as uuid } from 'uuid'; +interface GoogleDriveFilesItem { + id: string; + name: string; + mimeType: string; + webViewLink: string; +} + +interface GoogleDriveDriveItem { + id: string; + name: string; +} + export class GoogleDrive implements INodeType { description: INodeTypeDescription = { displayName: 'Google Drive', @@ -209,59 +223,146 @@ export class GoogleDrive implements INodeType { // file // ---------------------------------- - // ---------------------------------- - // file:copy - // ---------------------------------- { - displayName: 'ID', + displayName: 'File', name: 'fileId', - type: 'string', - default: '', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, required: true, + modes: [ + { + displayName: 'File', + name: 'list', + type: 'list', + placeholder: 'Select a file...', + typeOptions: { + searchListMethod: 'fileSearch', + searchable: true, + }, + }, + { + displayName: 'Link', + name: 'url', + type: 'string', + placeholder: + 'https://docs.google.com/spreadsheets/d/1-i6Vx0NN-3333eeeeeeeeee333333333/edit', + extractValue: { + type: 'regex', + regex: + 'https:\\/\\/(?:drive|docs)\\.google\\.com\\/\\w+\\/d\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', + }, + validation: [ + { + type: 'regex', + properties: { + regex: + 'https:\\/\\/(?:drive|docs)\\.google.com\\/\\w+\\/d\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', + errorMessage: 'Not a valid Google Drive File URL', + }, + }, + ], + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + placeholder: '1anGBg0b5re2VtF2bKu201_a-Vnz5BHq9Y4r-yBDAj5A', + validation: [ + { + type: 'regex', + properties: { + regex: '[a-zA-Z0-9\\-_]{2,}', + errorMessage: 'Not a valid Google Drive File ID', + }, + }, + ], + url: '=https://drive.google.com/file/d/{{$value}}/view', + }, + ], displayOptions: { show: { - operation: ['copy'], + operation: ['download', 'copy', 'update', 'delete', 'share'], resource: ['file'], }, }, - description: 'The ID of the file to copy', + description: 'The ID of the file', }, - // ---------------------------------- - // file/folder:delete - // ---------------------------------- { - displayName: 'ID', + displayName: 'Folder', name: 'fileId', - type: 'string', - default: '', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, required: true, + modes: [ + { + displayName: 'Folder', + name: 'list', + type: 'list', + placeholder: 'Select a folder...', + typeOptions: { + searchListMethod: 'folderSearch', + searchable: true, + }, + }, + { + displayName: 'Link', + name: 'url', + type: 'string', + placeholder: 'https://drive.google.com/drive/folders/1Tx9WHbA3wBpPB4C_HcoZDH9WZFWYxAMU', + extractValue: { + type: 'regex', + regex: + 'https:\\/\\/drive\\.google\\.com\\/\\w+\\/folders\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', + }, + validation: [ + { + type: 'regex', + properties: { + regex: + 'https:\\/\\/drive\\.google\\.com\\/\\w+\\/folders\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', + errorMessage: 'Not a valid Google Drive Folder URL', + }, + }, + ], + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + placeholder: '1anGBg0b5re2VtF2bKu201_a-Vnz5BHq9Y4r-yBDAj5A', + validation: [ + { + type: 'regex', + properties: { + regex: '[a-zA-Z0-9\\-_]{2,}', + errorMessage: 'Not a valid Google Drive Folder ID', + }, + }, + ], + url: '=https://drive.google.com/drive/folders/{{$value}}', + }, + ], displayOptions: { show: { - operation: ['delete'], - resource: ['file', 'folder'], + operation: ['delete', 'share'], + resource: ['folder'], }, }, - description: 'The ID of the file/folder to delete', + description: 'The ID of the folder', }, + // ---------------------------------- + // file:copy + // ---------------------------------- + + // ---------------------------------- + // file/folder:delete + // ---------------------------------- + // ---------------------------------- // file:download // ---------------------------------- - { - displayName: 'File ID', - name: 'fileId', - type: 'string', - default: '', - required: true, - displayOptions: { - show: { - operation: ['download'], - resource: ['file'], - }, - }, - description: 'The ID of the file to download', - }, { displayName: 'Binary Property', name: 'binaryPropertyName', @@ -621,20 +722,6 @@ export class GoogleDrive implements INodeType { // ---------------------------------- // file:share // ---------------------------------- - { - displayName: 'File ID', - name: 'fileId', - type: 'string', - default: '', - required: true, - displayOptions: { - show: { - operation: ['share'], - resource: ['file', 'folder'], - }, - }, - description: 'The ID of the file or shared drive', - }, { displayName: 'Permissions', name: 'permissionsUi', @@ -805,20 +892,6 @@ export class GoogleDrive implements INodeType { // ---------------------------------- // file:update // ---------------------------------- - { - displayName: 'ID', - name: 'fileId', - type: 'string', - default: '', - required: true, - displayOptions: { - show: { - operation: ['update'], - resource: ['file'], - }, - }, - description: 'The ID of the file to update', - }, { displayName: 'Update Fields', name: 'updateFields', @@ -1398,6 +1471,72 @@ export class GoogleDrive implements INodeType { ], default: 'create', }, + + { + displayName: 'Drive', + name: 'driveId', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + required: true, + hint: 'The Google Drive drive to operator on', + modes: [ + { + displayName: 'Drive', + name: 'list', + type: 'list', + placeholder: 'Drive', + typeOptions: { + searchListMethod: 'driveSearch', + searchable: true, + }, + }, + { + displayName: 'Link', + name: 'url', + type: 'string', + placeholder: 'https://drive.google.com/drive/folders/0AaaaaAAAAAAAaa', + extractValue: { + type: 'regex', + regex: + 'https:\\/\\/drive\\.google\\.com\\/\\w+\\/folders\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', + }, + validation: [ + { + type: 'regex', + properties: { + regex: + 'https:\\/\\/drive\\.google\\.com\\/\\w+\\/folders\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', + errorMessage: 'Not a valid Google Drive Drive URL', + }, + }, + ], + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + hint: 'The ID of the shared drive', + validation: [ + { + type: 'regex', + properties: { + regex: '[a-zA-Z0-9\\-_]{2,}', + errorMessage: 'Not a valid Google Drive Drive ID', + }, + }, + ], + url: '=https://drive.google.com/drive/folders/{{$value}}', + }, + ], + displayOptions: { + show: { + operation: ['delete', 'get', 'update'], + resource: ['drive'], + }, + }, + description: 'The ID of the drive', + }, + // ---------------------------------- // drive:create // ---------------------------------- @@ -1641,35 +1780,10 @@ export class GoogleDrive implements INodeType { // ---------------------------------- // drive:delete // ---------------------------------- - { - displayName: 'Drive ID', - name: 'driveId', - type: 'string', - default: '', - displayOptions: { - show: { - operation: ['delete'], - resource: ['drive'], - }, - }, - description: 'The ID of the shared drive', - }, + // ---------------------------------- // drive:get // ---------------------------------- - { - displayName: 'Drive ID', - name: 'driveId', - type: 'string', - default: '', - displayOptions: { - show: { - operation: ['get'], - resource: ['drive'], - }, - }, - description: 'The ID of the shared drive', - }, { displayName: 'Options', name: 'options', @@ -1761,19 +1875,6 @@ export class GoogleDrive implements INodeType { // ---------------------------------- // drive:update // ---------------------------------- - { - displayName: 'Drive ID', - name: 'driveId', - type: 'string', - default: '', - displayOptions: { - show: { - operation: ['update'], - resource: ['drive'], - }, - }, - description: 'The ID of the shared drive', - }, { displayName: 'Update Fields', name: 'options', @@ -1929,6 +2030,78 @@ export class GoogleDrive implements INodeType { ], }; + methods = { + listSearch: { + async fileSearch( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, + ): Promise { + const query: string[] = []; + if (filter) { + query.push(`name contains '${filter.replace("'", "\\'")}'`); + } + query.push(`mimeType != 'application/vnd.google-apps.folder'`); + const res = await googleApiRequest.call(this, 'GET', '/drive/v3/files', undefined, { + q: query.join(' and '), + pageToken: paginationToken as string | undefined, + fields: 'nextPageToken,files(id,name,mimeType,webViewLink)', + orderBy: 'name_natural', + }); + return { + results: res.files.map((i: GoogleDriveFilesItem) => ({ + name: i.name, + value: i.id, + url: i.webViewLink, + })), + paginationToken: res.nextPageToken, + }; + }, + async folderSearch( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, + ): Promise { + const query: string[] = []; + if (filter) { + query.push(`name contains '${filter.replace("'", "\\'")}'`); + } + query.push(`mimeType = 'application/vnd.google-apps.folder'`); + const res = await googleApiRequest.call(this, 'GET', '/drive/v3/files', undefined, { + q: query.join(' and '), + pageToken: paginationToken as string | undefined, + fields: 'nextPageToken,files(id,name,mimeType,webViewLink)', + orderBy: 'name_natural', + }); + return { + results: res.files.map((i: GoogleDriveFilesItem) => ({ + name: i.name, + value: i.id, + url: i.webViewLink, + })), + paginationToken: res.nextPageToken, + }; + }, + async driveSearch( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, + ): Promise { + const res = await googleApiRequest.call(this, 'GET', '/drive/v3/drives', undefined, { + q: filter ? `name contains '${filter.replace("'", "\\'")}'` : undefined, + pageToken: paginationToken as string | undefined, + }); + return { + results: res.drives.map((i: GoogleDriveDriveItem) => ({ + name: i.name, + value: i.id, + })), + paginationToken: res.nextPageToken, + }; + }, + }, + }; + async execute(this: IExecuteFunctions): Promise { const items = this.getInputData(); const returnData: INodeExecutionData[] = []; @@ -1964,7 +2137,9 @@ export class GoogleDrive implements INodeType { Object.assign(body, options); - const response = await googleApiRequest.call(this, 'POST', `/drive/v3/drives`, body, { requestId: uuid() }); + const response = await googleApiRequest.call(this, 'POST', `/drive/v3/drives`, body, { + requestId: uuid(), + }); const executionData = this.helpers.constructExecutionMetaData( this.helpers.returnJsonArray(response), @@ -1978,7 +2153,9 @@ export class GoogleDrive implements INodeType { // delete // ---------------------------------- - const driveId = this.getNodeParameter('driveId', i) as string; + const driveId = this.getNodeParameter('driveId', i, undefined, { + extractValue: true, + }) as string; await googleApiRequest.call(this, 'DELETE', `/drive/v3/drives/${driveId}`); @@ -1994,13 +2171,21 @@ export class GoogleDrive implements INodeType { // get // ---------------------------------- - const driveId = this.getNodeParameter('driveId', i) as string; + const driveId = this.getNodeParameter('driveId', i, undefined, { + extractValue: true, + }) as string; const qs: IDataObject = {}; Object.assign(qs, options); - const response = await googleApiRequest.call(this, 'GET', `/drive/v3/drives/${driveId}`, {}, qs); + const response = await googleApiRequest.call( + this, + 'GET', + `/drive/v3/drives/${driveId}`, + {}, + qs, + ); const executionData = this.helpers.constructExecutionMetaData( this.helpers.returnJsonArray(response), @@ -2048,13 +2233,20 @@ export class GoogleDrive implements INodeType { // update // ---------------------------------- - const driveId = this.getNodeParameter('driveId', i) as string; + const driveId = this.getNodeParameter('driveId', i, undefined, { + extractValue: true, + }) as string; const body: IDataObject = {}; Object.assign(body, options); - const response = await googleApiRequest.call(this, 'PATCH', `/drive/v3/drives/${driveId}`, body); + const response = await googleApiRequest.call( + this, + 'PATCH', + `/drive/v3/drives/${driveId}`, + body, + ); const executionData = this.helpers.constructExecutionMetaData( this.helpers.returnJsonArray(response), @@ -2070,7 +2262,9 @@ export class GoogleDrive implements INodeType { // copy // ---------------------------------- - const fileId = this.getNodeParameter('fileId', i) as string; + const fileId = this.getNodeParameter('fileId', i, undefined, { + extractValue: true, + }) as string; const body: IDataObject = { fields: queryFields, @@ -2087,7 +2281,13 @@ export class GoogleDrive implements INodeType { supportsAllDrives: true, }; - const response = await googleApiRequest.call(this, 'POST', `/drive/v3/files/${fileId}/copy`, body, qs); + const response = await googleApiRequest.call( + this, + 'POST', + `/drive/v3/files/${fileId}/copy`, + body, + qs, + ); const executionData = this.helpers.constructExecutionMetaData( this.helpers.returnJsonArray(response), @@ -2100,7 +2300,9 @@ export class GoogleDrive implements INodeType { // download // ---------------------------------- - const fileId = this.getNodeParameter('fileId', i) as string; + const fileId = this.getNodeParameter('fileId', i, undefined, { + extractValue: true, + }) as string; const options = this.getNodeParameter('options', i) as IDataObject; const requestOptions = { @@ -2443,7 +2645,9 @@ export class GoogleDrive implements INodeType { // file:update // ---------------------------------- - const id = this.getNodeParameter('fileId', i) as string; + const id = this.getNodeParameter('fileId', i, undefined, { + extractValue: true, + }) as string; const updateFields = this.getNodeParameter('updateFields', i, {}) as IDataObject; const qs: IDataObject = { @@ -2517,7 +2721,9 @@ export class GoogleDrive implements INodeType { // delete // ---------------------------------- - const fileId = this.getNodeParameter('fileId', i) as string; + const fileId = this.getNodeParameter('fileId', i, undefined, { + extractValue: true, + }) as string; await googleApiRequest.call( this, @@ -2539,7 +2745,9 @@ export class GoogleDrive implements INodeType { returnData.push(...executionData); } if (operation === 'share') { - const fileId = this.getNodeParameter('fileId', i) as string; + const fileId = this.getNodeParameter('fileId', i, undefined, { + extractValue: true, + }) as string; const permissions = this.getNodeParameter('permissionsUi', i) as IDataObject; @@ -2577,7 +2785,7 @@ export class GoogleDrive implements INodeType { if (resource === 'file' && operation === 'download') { items[i].json = { error: error.message }; } else { - returnData.push({ json: {error: error.message} }); + returnData.push({ json: { error: error.message } }); } continue; } diff --git a/packages/nodes-base/nodes/Trello/AttachmentDescription.ts b/packages/nodes-base/nodes/Trello/AttachmentDescription.ts index cad1397ebaca0..385676b92bf6c 100644 --- a/packages/nodes-base/nodes/Trello/AttachmentDescription.ts +++ b/packages/nodes-base/nodes/Trello/AttachmentDescription.ts @@ -45,23 +45,71 @@ export const attachmentOperations: INodeProperties[] = [ ]; export const attachmentFields: INodeProperties[] = [ - // ---------------------------------- - // attachment:create - // ---------------------------------- { displayName: 'Card ID', name: 'cardId', - type: 'string', - default: '', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, required: true, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a Card...', + typeOptions: { + searchListMethod: 'searchCards', + searchFilterRequired: true, + searchable: true, + }, + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + placeholder: 'https://trello.com/c/e123456/card-name', + validation: [ + { + type: 'regex', + properties: { + regex: 'http(s)?://trello.com/c/([a-zA-Z0-9]{2,})/.*', + errorMessage: 'Not a valid Trello Card URL', + }, + }, + ], + extractValue: { + type: 'regex', + regex: 'https://trello.com/c/([a-zA-Z0-9]{2,})', + }, + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: '[a-zA-Z0-9]{2,}', + errorMessage: 'Not a valid Trello Card ID', + }, + }, + ], + placeholder: 'wiIaGwqE', + url: '=https://trello.com/c/{{$value}}', + }, + ], displayOptions: { show: { - operation: ['create'], + operation: ['delete', 'create', 'get', 'getAll'], resource: ['attachment'], }, }, - description: 'The ID of the card to add attachment to', + description: 'The ID of the card', }, + // ---------------------------------- + // attachment:create + // ---------------------------------- { displayName: 'Source URL', name: 'url', @@ -110,20 +158,6 @@ export const attachmentFields: INodeProperties[] = [ // ---------------------------------- // attachment:delete // ---------------------------------- - { - displayName: 'Card ID', - name: 'cardId', - type: 'string', - default: '', - required: true, - displayOptions: { - show: { - operation: ['delete'], - resource: ['attachment'], - }, - }, - description: 'The ID of the card that attachment belongs to', - }, { displayName: 'Attachment ID', name: 'id', @@ -142,20 +176,6 @@ export const attachmentFields: INodeProperties[] = [ // ---------------------------------- // attachment:getAll // ---------------------------------- - { - displayName: 'Card ID', - name: 'cardId', - type: 'string', - default: '', - required: true, - displayOptions: { - show: { - operation: ['getAll'], - resource: ['attachment'], - }, - }, - description: 'The ID of the card to get attachments', - }, { displayName: 'Additional Fields', name: 'additionalFields', @@ -182,20 +202,6 @@ export const attachmentFields: INodeProperties[] = [ // ---------------------------------- // attachment:get // ---------------------------------- - { - displayName: 'Card ID', - name: 'cardId', - type: 'string', - default: '', - required: true, - displayOptions: { - show: { - operation: ['get'], - resource: ['attachment'], - }, - }, - description: 'The ID of the card to get attachment', - }, { displayName: 'Attachment ID', name: 'id', diff --git a/packages/nodes-base/nodes/Trello/BoardDescription.ts b/packages/nodes-base/nodes/Trello/BoardDescription.ts index ccd0f1a60a477..ca61288900ea1 100644 --- a/packages/nodes-base/nodes/Trello/BoardDescription.ts +++ b/packages/nodes-base/nodes/Trello/BoardDescription.ts @@ -294,41 +294,73 @@ export const boardFields: INodeProperties[] = [ ], }, - // ---------------------------------- - // board:delete - // ---------------------------------- { - displayName: 'Board ID', + displayName: 'Board', name: 'id', - type: 'string', - default: '', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, required: true, displayOptions: { show: { - operation: ['delete'], + operation: ['get', 'delete', 'update'], resource: ['board'], }, }, - description: 'The ID of the board to delete', + description: 'The ID of the board', + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a Board...', + initType: 'board', + typeOptions: { + searchListMethod: 'searchBoards', + searchFilterRequired: true, + searchable: true, + }, + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + placeholder: 'https://trello.com/b/e123456/board-name', + validation: [ + { + type: 'regex', + properties: { + regex: 'http(s)?://trello.com/b/([a-zA-Z0-9]{2,})/.*', + errorMessage: 'Not a valid Trello Board URL', + }, + }, + ], + extractValue: { + type: 'regex', + regex: 'https://trello.com/b/([a-zA-Z0-9]{2,})', + }, + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: '[a-zA-Z0-9]{2,}', + errorMessage: 'Not a valid Trello Board ID', + }, + }, + ], + placeholder: 'KdEAAdde', + url: '=https://trello.com/b/{{$value}}', + }, + ], }, // ---------------------------------- // board:get // ---------------------------------- - { - displayName: 'Board ID', - name: 'id', - type: 'string', - default: '', - required: true, - displayOptions: { - show: { - operation: ['get'], - resource: ['board'], - }, - }, - description: 'The ID of the board to get', - }, { displayName: 'Additional Fields', name: 'additionalFields', @@ -363,20 +395,6 @@ export const boardFields: INodeProperties[] = [ // ---------------------------------- // board:update // ---------------------------------- - { - displayName: 'Board ID', - name: 'id', - type: 'string', - default: '', - required: true, - displayOptions: { - show: { - operation: ['update'], - resource: ['board'], - }, - }, - description: 'The ID of the board to update', - }, { displayName: 'Update Fields', name: 'updateFields', diff --git a/packages/nodes-base/nodes/Trello/CardCommentDescription.ts b/packages/nodes-base/nodes/Trello/CardCommentDescription.ts index b3a733077328e..b19863b1ea39b 100644 --- a/packages/nodes-base/nodes/Trello/CardCommentDescription.ts +++ b/packages/nodes-base/nodes/Trello/CardCommentDescription.ts @@ -36,23 +36,72 @@ export const cardCommentOperations: INodeProperties[] = [ ]; export const cardCommentFields: INodeProperties[] = [ - // ---------------------------------- - // cardComment:create - // ---------------------------------- { - displayName: 'Card ID', + displayName: 'Card', name: 'cardId', - type: 'string', - default: '', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, required: true, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a Card...', + typeOptions: { + searchListMethod: 'searchCards', + searchFilterRequired: true, + searchable: true, + }, + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + placeholder: 'https://trello.com/c/e123456/card-name', + validation: [ + { + type: 'regex', + properties: { + regex: 'http(s)?://trello.com/c/([a-zA-Z0-9]{2,})/.*', + errorMessage: 'Not a valid Trello Card URL', + }, + }, + ], + extractValue: { + type: 'regex', + regex: 'https://trello.com/c/([a-zA-Z0-9]{2,})', + }, + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: '[a-zA-Z0-9]{2,}', + errorMessage: 'Not a valid Trello Card ID', + }, + }, + ], + placeholder: 'wiIaGwqE', + url: '=https://trello.com/c/{{$value}}', + }, + ], displayOptions: { show: { - operation: ['create'], + operation: ['update', 'delete', 'create'], resource: ['cardComment'], }, }, description: 'The ID of the card', }, + + // ---------------------------------- + // cardComment:create + // ---------------------------------- { displayName: 'Text', name: 'text', @@ -71,20 +120,6 @@ export const cardCommentFields: INodeProperties[] = [ // ---------------------------------- // cardComment:remove // ---------------------------------- - { - displayName: 'Card ID', - name: 'cardId', - type: 'string', - default: '', - required: true, - displayOptions: { - show: { - operation: ['delete'], - resource: ['cardComment'], - }, - }, - description: 'The ID of the card', - }, { displayName: 'Comment ID', name: 'commentId', @@ -103,20 +138,6 @@ export const cardCommentFields: INodeProperties[] = [ // ---------------------------------- // cardComment:update // ---------------------------------- - { - displayName: 'Card ID', - name: 'cardId', - type: 'string', - default: '', - required: true, - displayOptions: { - show: { - operation: ['update'], - resource: ['cardComment'], - }, - }, - description: 'The ID of the card to update', - }, { displayName: 'Comment ID', name: 'commentId', diff --git a/packages/nodes-base/nodes/Trello/CardDescription.ts b/packages/nodes-base/nodes/Trello/CardDescription.ts index 2acd2ede0ec8b..6d0d29e79f8b9 100644 --- a/packages/nodes-base/nodes/Trello/CardDescription.ts +++ b/packages/nodes-base/nodes/Trello/CardDescription.ts @@ -163,41 +163,72 @@ export const cardFields: INodeProperties[] = [ ], }, - // ---------------------------------- - // card:delete - // ---------------------------------- { - displayName: 'Card ID', + displayName: 'Card', name: 'id', - type: 'string', - default: '', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, required: true, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a Card...', + typeOptions: { + searchListMethod: 'searchCards', + searchFilterRequired: true, + searchable: true, + }, + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + placeholder: 'https://trello.com/c/e123456/card-name', + validation: [ + { + type: 'regex', + properties: { + regex: 'http(s)?://trello.com/c/([a-zA-Z0-9]{2,})/.*', + errorMessage: 'Not a valid Trello Card URL', + }, + }, + ], + extractValue: { + type: 'regex', + regex: 'https://trello.com/c/([a-zA-Z0-9]{2,})', + }, + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: '[a-zA-Z0-9]{2,}', + errorMessage: 'Not a valid Trello Card ID', + }, + }, + ], + placeholder: 'wiIaGwqE', + url: '=https://trello.com/c/{{$value}}', + }, + ], displayOptions: { show: { - operation: ['delete'], + operation: ['get', 'delete', 'update'], resource: ['card'], }, }, - description: 'The ID of the card to delete', + description: 'The ID of the card', }, // ---------------------------------- // card:get // ---------------------------------- - { - displayName: 'Card ID', - name: 'id', - type: 'string', - default: '', - required: true, - displayOptions: { - show: { - operation: ['get'], - resource: ['card'], - }, - }, - description: 'The ID of the card to get', - }, { displayName: 'Additional Fields', name: 'additionalFields', @@ -285,20 +316,6 @@ export const cardFields: INodeProperties[] = [ // ---------------------------------- // card:update // ---------------------------------- - { - displayName: 'Card ID', - name: 'id', - type: 'string', - default: '', - required: true, - displayOptions: { - show: { - operation: ['update'], - resource: ['card'], - }, - }, - description: 'The ID of the card to update', - }, { displayName: 'Update Fields', name: 'updateFields', diff --git a/packages/nodes-base/nodes/Trello/ChecklistDescription.ts b/packages/nodes-base/nodes/Trello/ChecklistDescription.ts index 658f30980130d..5e3a76b42f483 100644 --- a/packages/nodes-base/nodes/Trello/ChecklistDescription.ts +++ b/packages/nodes-base/nodes/Trello/ChecklistDescription.ts @@ -75,23 +75,80 @@ export const checklistOperations: INodeProperties[] = [ ]; export const checklistFields: INodeProperties[] = [ - // ---------------------------------- - // checklist:create - // ---------------------------------- { - displayName: 'Card ID', + displayName: 'Card', name: 'cardId', - type: 'string', - default: '', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, required: true, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a Card...', + typeOptions: { + searchListMethod: 'searchCards', + searchFilterRequired: true, + searchable: true, + }, + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + placeholder: 'https://trello.com/c/e123456/card-name', + validation: [ + { + type: 'regex', + properties: { + regex: 'http(s)?://trello.com/c/([a-zA-Z0-9]{2,})/.*', + errorMessage: 'Not a valid Trello Card URL', + }, + }, + ], + extractValue: { + type: 'regex', + regex: 'https://trello.com/c/([a-zA-Z0-9]{2,})', + }, + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: '[a-zA-Z0-9]{2,}', + errorMessage: 'Not a valid Trello Card ID', + }, + }, + ], + placeholder: 'wiIaGwqE', + url: '=https://trello.com/c/{{$value}}', + }, + ], displayOptions: { show: { - operation: ['create'], + operation: [ + 'delete', + 'create', + 'getAll', + 'deleteCheckItem', + 'getCheckItem', + 'updateCheckItem', + 'completeCheckItems', + ], resource: ['checklist'], }, }, - description: 'The ID of the card to add checklist to', + description: 'The ID of the card', }, + + // ---------------------------------- + // checklist:create + // ---------------------------------- { displayName: 'Name', name: 'name', @@ -140,20 +197,6 @@ export const checklistFields: INodeProperties[] = [ // ---------------------------------- // checklist:delete // ---------------------------------- - { - displayName: 'Card ID', - name: 'cardId', - type: 'string', - default: '', - required: true, - displayOptions: { - show: { - operation: ['delete'], - resource: ['checklist'], - }, - }, - description: 'The ID of the card that checklist belongs to', - }, { displayName: 'Checklist ID', name: 'id', @@ -172,20 +215,6 @@ export const checklistFields: INodeProperties[] = [ // ---------------------------------- // checklist:getAll // ---------------------------------- - { - displayName: 'Card ID', - name: 'cardId', - type: 'string', - default: '', - required: true, - displayOptions: { - show: { - operation: ['getAll'], - resource: ['checklist'], - }, - }, - description: 'The ID of the card to get checklists', - }, { displayName: 'Additional Fields', name: 'additionalFields', @@ -314,20 +343,6 @@ export const checklistFields: INodeProperties[] = [ // ---------------------------------- // checklist:deleteCheckItem // ---------------------------------- - { - displayName: 'Card ID', - name: 'cardId', - type: 'string', - default: '', - required: true, - displayOptions: { - show: { - operation: ['deleteCheckItem'], - resource: ['checklist'], - }, - }, - description: 'The ID of the card that checklist belongs to', - }, { displayName: 'CheckItem ID', name: 'checkItemId', @@ -346,20 +361,6 @@ export const checklistFields: INodeProperties[] = [ // ---------------------------------- // checklist:getCheckItem // ---------------------------------- - { - displayName: 'Card ID', - name: 'cardId', - type: 'string', - default: '', - required: true, - displayOptions: { - show: { - operation: ['getCheckItem'], - resource: ['checklist'], - }, - }, - description: 'The ID of the card that checklist belongs to', - }, { displayName: 'CheckItem ID', name: 'checkItemId', @@ -400,20 +401,6 @@ export const checklistFields: INodeProperties[] = [ // ---------------------------------- // checklist:updateCheckItem // ---------------------------------- - { - displayName: 'Card ID', - name: 'cardId', - type: 'string', - default: '', - required: true, - displayOptions: { - show: { - operation: ['updateCheckItem'], - resource: ['checklist'], - }, - }, - description: 'The ID of the card that checklist belongs to', - }, { displayName: 'CheckItem ID', name: 'checkItemId', @@ -485,20 +472,6 @@ export const checklistFields: INodeProperties[] = [ // ---------------------------------- // checklist:completedCheckItems // ---------------------------------- - { - displayName: 'Card ID', - name: 'cardId', - type: 'string', - default: '', - required: true, - displayOptions: { - show: { - operation: ['completedCheckItems'], - resource: ['checklist'], - }, - }, - description: 'The ID of the card for checkItems', - }, { displayName: 'Additional Fields', name: 'additionalFields', diff --git a/packages/nodes-base/nodes/Trello/GenericFunctions.ts b/packages/nodes-base/nodes/Trello/GenericFunctions.ts index 68ef5a9d11cde..1452a7bbc21c1 100644 --- a/packages/nodes-base/nodes/Trello/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Trello/GenericFunctions.ts @@ -34,6 +34,9 @@ export async function apiRequest( try { return await this.helpers.requestWithAuthentication.call(this, 'trelloApi', options); } catch (error) { + if (error instanceof NodeApiError) { + throw error; + } throw new NodeApiError(this.getNode(), error as JsonObject); } } diff --git a/packages/nodes-base/nodes/Trello/LabelDescription.ts b/packages/nodes-base/nodes/Trello/LabelDescription.ts index 5bd1ff93ee02a..b3e9c54bee463 100644 --- a/packages/nodes-base/nodes/Trello/LabelDescription.ts +++ b/packages/nodes-base/nodes/Trello/LabelDescription.ts @@ -63,23 +63,73 @@ export const labelOperations: INodeProperties[] = [ ]; export const labelFields: INodeProperties[] = [ - // ---------------------------------- - // label:create - // ---------------------------------- { - displayName: 'Board ID', + displayName: 'Board', name: 'boardId', - type: 'string', - default: '', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, required: true, displayOptions: { show: { - operation: ['create'], + operation: ['create', 'getAll'], resource: ['label'], }, }, - description: 'The ID of the board to create the label on', + description: 'The ID of the board', + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a Board...', + initType: 'board', + typeOptions: { + searchListMethod: 'searchBoards', + searchFilterRequired: true, + searchable: true, + }, + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + placeholder: 'https://trello.com/b/e123456/board-name', + validation: [ + { + type: 'regex', + properties: { + regex: 'http(s)?://trello.com/b/([a-zA-Z0-9]{2,})/.*', + errorMessage: 'Not a valid Trello Board URL', + }, + }, + ], + extractValue: { + type: 'regex', + regex: 'https://trello.com/b/([a-zA-Z0-9]{2,})', + }, + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: '[a-zA-Z0-9]{2,}', + errorMessage: 'Not a valid Trello Board ID', + }, + }, + ], + placeholder: 'KdEAAdde', + url: '=https://trello.com/b/{{$value}}', + }, + ], }, + + // ---------------------------------- + // label:create + // ---------------------------------- { displayName: 'Name', name: 'name', @@ -176,20 +226,6 @@ export const labelFields: INodeProperties[] = [ // ---------------------------------- // label:getAll // ---------------------------------- - { - displayName: 'Board ID', - name: 'boardId', - type: 'string', - default: '', - required: true, - displayOptions: { - show: { - operation: ['getAll'], - resource: ['label'], - }, - }, - description: 'The ID of the board to get label', - }, { displayName: 'Additional Fields', name: 'additionalFields', @@ -253,23 +289,72 @@ export const labelFields: INodeProperties[] = [ ], }, - // ---------------------------------- - // label:addLabel - // ---------------------------------- { displayName: 'Card ID', name: 'cardId', - type: 'string', - default: '', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, required: true, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Choose...', + typeOptions: { + searchListMethod: 'searchCards', + searchFilterRequired: true, + searchable: true, + }, + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + placeholder: 'https://trello.com/c/e123456/card-name', + validation: [ + { + type: 'regex', + properties: { + regex: 'http(s)?://trello.com/c/([a-zA-Z0-9]{2,})/.*', + errorMessage: 'Not a valid Trello Card URL', + }, + }, + ], + extractValue: { + type: 'regex', + regex: 'https://trello.com/c/([a-zA-Z0-9]{2,})', + }, + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: '[a-zA-Z0-9]{2,}', + errorMessage: 'Not a valid Trello Card ID', + }, + }, + ], + placeholder: 'wiIaGwqE', + url: '=https://trello.com/c/{{$value}}', + }, + ], displayOptions: { show: { - operation: ['addLabel'], + operation: ['addLabel', 'removeLabel'], resource: ['label'], }, }, - description: 'The ID of the card to get label', + description: 'The ID of the card', }, + + // ---------------------------------- + // label:addLabel + // ---------------------------------- { displayName: 'Label ID', name: 'id', @@ -288,20 +373,6 @@ export const labelFields: INodeProperties[] = [ // ---------------------------------- // label:removeLabel // ---------------------------------- - { - displayName: 'Card ID', - name: 'cardId', - type: 'string', - default: '', - required: true, - displayOptions: { - show: { - operation: ['removeLabel'], - resource: ['label'], - }, - }, - description: 'The ID of the card to remove label from', - }, { displayName: 'Label ID', name: 'id', diff --git a/packages/nodes-base/nodes/Trello/Trello.node.ts b/packages/nodes-base/nodes/Trello/Trello.node.ts index 1dbe726e0b648..ad1050adfe23b 100644 --- a/packages/nodes-base/nodes/Trello/Trello.node.ts +++ b/packages/nodes-base/nodes/Trello/Trello.node.ts @@ -1,8 +1,9 @@ -import { IExecuteFunctions } from 'n8n-core'; +import { IExecuteFunctions, ILoadOptionsFunctions } from 'n8n-core'; import { IDataObject, INodeExecutionData, + INodeListSearchResult, INodeType, INodeTypeDescription, NodeOperationError, @@ -26,6 +27,17 @@ import { labelFields, labelOperations } from './LabelDescription'; import { listFields, listOperations } from './ListDescription'; +interface TrelloBoardType { + id: string; + name: string; + url: string; + desc: string; +} + +// We retrieve the same fields. This is just to make it clear it's not actually +// getting boards back. +type TrelloCardType = TrelloBoardType; + export class Trello implements INodeType { description: INodeTypeDescription = { displayName: 'Trello', @@ -115,6 +127,73 @@ export class Trello implements INodeType { ], }; + methods = { + listSearch: { + async searchBoards( + this: ILoadOptionsFunctions, + query?: string, + ): Promise { + if (!query) { + throw new NodeOperationError(this.getNode(), 'Query required for Trello search'); + } + const searchResults = await apiRequest.call( + this, + 'GET', + 'search', + {}, + { + query, + modelTypes: 'boards', + board_fields: 'name,url,desc', + // Enables partial word searching, only for the start of words though + partial: true, + // Seems like a good number since it isn't paginated. Default is 10. + boards_limit: 50, + }, + ); + return { + results: searchResults.boards.map((b: TrelloBoardType) => ({ + name: b.name, + value: b.id, + url: b.url, + description: b.desc, + })), + }; + }, + async searchCards( + this: ILoadOptionsFunctions, + query?: string, + ): Promise { + if (!query) { + throw new NodeOperationError(this.getNode(), 'Query required for Trello search'); + } + const searchResults = await apiRequest.call( + this, + 'GET', + 'search', + {}, + { + query, + modelTypes: 'cards', + board_fields: 'name,url,desc', + // Enables partial word searching, only for the start of words though + partial: true, + // Seems like a good number since it isn't paginated. Default is 10. + cards_limit: 50, + }, + ); + return { + results: searchResults.cards.map((b: TrelloBoardType) => ({ + name: b.name, + value: b.id, + url: b.url, + description: b.desc, + })), + }; + }, + }, + }; + async execute(this: IExecuteFunctions): Promise { const items = this.getInputData(); const returnData: INodeExecutionData[] = []; @@ -160,7 +239,9 @@ export class Trello implements INodeType { requestMethod = 'DELETE'; - const id = this.getNodeParameter('id', i) as string; + const id = this.getNodeParameter('id', i, undefined, { + extractValue: true, + }) as string; endpoint = `boards/${id}`; } else if (operation === 'get') { @@ -170,7 +251,7 @@ export class Trello implements INodeType { requestMethod = 'GET'; - const id = this.getNodeParameter('id', i) as string; + const id = this.getNodeParameter('id', i, undefined, { extractValue: true }); endpoint = `boards/${id}`; @@ -183,7 +264,7 @@ export class Trello implements INodeType { requestMethod = 'PUT'; - const id = this.getNodeParameter('id', i) as string; + const id = this.getNodeParameter('id', i, undefined, { extractValue: true }); endpoint = `boards/${id}`; @@ -286,7 +367,7 @@ export class Trello implements INodeType { requestMethod = 'DELETE'; - const id = this.getNodeParameter('id', i) as string; + const id = this.getNodeParameter('id', i, undefined, { extractValue: true }); endpoint = `cards/${id}`; } else if (operation === 'get') { @@ -296,7 +377,7 @@ export class Trello implements INodeType { requestMethod = 'GET'; - const id = this.getNodeParameter('id', i) as string; + const id = this.getNodeParameter('id', i, undefined, { extractValue: true }); endpoint = `cards/${id}`; @@ -309,7 +390,7 @@ export class Trello implements INodeType { requestMethod = 'PUT'; - const id = this.getNodeParameter('id', i) as string; + const id = this.getNodeParameter('id', i, undefined, { extractValue: true }); endpoint = `cards/${id}`; @@ -328,7 +409,9 @@ export class Trello implements INodeType { // create // ---------------------------------- - const cardId = this.getNodeParameter('cardId', i) as string; + const cardId = this.getNodeParameter('cardId', i, undefined, { + extractValue: true, + }) as string; qs.text = this.getNodeParameter('text', i) as string; @@ -342,7 +425,9 @@ export class Trello implements INodeType { requestMethod = 'DELETE'; - const cardId = this.getNodeParameter('cardId', i) as string; + const cardId = this.getNodeParameter('cardId', i, undefined, { + extractValue: true, + }) as string; const commentId = this.getNodeParameter('commentId', i) as string; @@ -354,7 +439,9 @@ export class Trello implements INodeType { requestMethod = 'PUT'; - const cardId = this.getNodeParameter('cardId', i) as string; + const cardId = this.getNodeParameter('cardId', i, undefined, { + extractValue: true, + }) as string; const commentId = this.getNodeParameter('commentId', i) as string; @@ -473,7 +560,10 @@ export class Trello implements INodeType { requestMethod = 'POST'; - const cardId = this.getNodeParameter('cardId', i) as string; + const cardId = this.getNodeParameter('cardId', i, undefined, { + extractValue: true, + }) as string; + const url = this.getNodeParameter('url', i) as string; Object.assign(qs, { @@ -491,7 +581,10 @@ export class Trello implements INodeType { requestMethod = 'DELETE'; - const cardId = this.getNodeParameter('cardId', i) as string; + const cardId = this.getNodeParameter('cardId', i, undefined, { + extractValue: true, + }) as string; + const id = this.getNodeParameter('id', i) as string; endpoint = `cards/${cardId}/attachments/${id}`; @@ -502,7 +595,10 @@ export class Trello implements INodeType { requestMethod = 'GET'; - const cardId = this.getNodeParameter('cardId', i) as string; + const cardId = this.getNodeParameter('cardId', i, undefined, { + extractValue: true, + }) as string; + const id = this.getNodeParameter('id', i) as string; endpoint = `cards/${cardId}/attachments/${id}`; @@ -516,7 +612,9 @@ export class Trello implements INodeType { requestMethod = 'GET'; - const cardId = this.getNodeParameter('cardId', i) as string; + const cardId = this.getNodeParameter('cardId', i, undefined, { + extractValue: true, + }) as string; endpoint = `cards/${cardId}/attachments`; @@ -537,7 +635,10 @@ export class Trello implements INodeType { requestMethod = 'POST'; - const cardId = this.getNodeParameter('cardId', i) as string; + const cardId = this.getNodeParameter('cardId', i, undefined, { + extractValue: true, + }) as string; + const name = this.getNodeParameter('name', i) as string; Object.assign(qs, { name }); @@ -553,7 +654,10 @@ export class Trello implements INodeType { requestMethod = 'DELETE'; - const cardId = this.getNodeParameter('cardId', i) as string; + const cardId = this.getNodeParameter('cardId', i, undefined, { + extractValue: true, + }) as string; + const id = this.getNodeParameter('id', i) as string; endpoint = `cards/${cardId}/checklists/${id}`; @@ -577,7 +681,9 @@ export class Trello implements INodeType { requestMethod = 'GET'; - const cardId = this.getNodeParameter('cardId', i) as string; + const cardId = this.getNodeParameter('cardId', i, undefined, { + extractValue: true, + }) as string; endpoint = `cards/${cardId}/checklists`; @@ -590,7 +696,10 @@ export class Trello implements INodeType { requestMethod = 'GET'; - const cardId = this.getNodeParameter('cardId', i) as string; + const cardId = this.getNodeParameter('cardId', i, undefined, { + extractValue: true, + }) as string; + const checkItemId = this.getNodeParameter('checkItemId', i) as string; endpoint = `cards/${cardId}/checkItem/${checkItemId}`; @@ -618,7 +727,10 @@ export class Trello implements INodeType { requestMethod = 'DELETE'; - const cardId = this.getNodeParameter('cardId', i) as string; + const cardId = this.getNodeParameter('cardId', i, undefined, { + extractValue: true, + }) as string; + const checkItemId = this.getNodeParameter('checkItemId', i) as string; endpoint = `cards/${cardId}/checkItem/${checkItemId}`; @@ -629,7 +741,10 @@ export class Trello implements INodeType { requestMethod = 'PUT'; - const cardId = this.getNodeParameter('cardId', i) as string; + const cardId = this.getNodeParameter('cardId', i, undefined, { + extractValue: true, + }) as string; + const checkItemId = this.getNodeParameter('checkItemId', i) as string; endpoint = `cards/${cardId}/checkItem/${checkItemId}`; @@ -643,7 +758,9 @@ export class Trello implements INodeType { requestMethod = 'GET'; - const cardId = this.getNodeParameter('cardId', i) as string; + const cardId = this.getNodeParameter('cardId', i, undefined, { + extractValue: true, + }) as string; endpoint = `cards/${cardId}/checkItemStates`; @@ -664,7 +781,10 @@ export class Trello implements INodeType { requestMethod = 'POST'; - const idBoard = this.getNodeParameter('boardId', i) as string; + const idBoard = this.getNodeParameter('boardId', i, undefined, { + extractValue: true, + }) as string; + const name = this.getNodeParameter('name', i) as string; const color = this.getNodeParameter('color', i) as string; @@ -705,7 +825,9 @@ export class Trello implements INodeType { requestMethod = 'GET'; - const idBoard = this.getNodeParameter('boardId', i) as string; + const idBoard = this.getNodeParameter('boardId', i, undefined, { + extractValue: true, + }) as string; endpoint = `board/${idBoard}/labels`; @@ -732,7 +854,10 @@ export class Trello implements INodeType { requestMethod = 'POST'; - const cardId = this.getNodeParameter('cardId', i) as string; + const cardId = this.getNodeParameter('cardId', i, undefined, { + extractValue: true, + }) as string; + const id = this.getNodeParameter('id', i) as string; qs.value = id; @@ -745,7 +870,10 @@ export class Trello implements INodeType { requestMethod = 'DELETE'; - const cardId = this.getNodeParameter('cardId', i) as string; + const cardId = this.getNodeParameter('cardId', i, undefined, { + extractValue: true, + }) as string; + const id = this.getNodeParameter('id', i) as string; endpoint = `/cards/${cardId}/idLabels/${id}`; diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index a5407bb397b28..5201d27df4007 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -734,7 +734,7 @@ "@types/tmp": "^0.2.0", "@types/uuid": "^8.3.2", "@types/xml2js": "^0.4.3", - "eslint-plugin-n8n-nodes-base": "^1.9.1", + "eslint-plugin-n8n-nodes-base": "^1.9.3", "gulp": "^4.0.0", "jest": "^27.4.7", "n8n-workflow": "~0.116.0", diff --git a/packages/workflow/src/Expression.ts b/packages/workflow/src/Expression.ts index d2e07001af266..fc56f2c0b03ec 100644 --- a/packages/workflow/src/Expression.ts +++ b/packages/workflow/src/Expression.ts @@ -7,11 +7,13 @@ import { IExecuteData, INode, INodeExecutionData, + INodeParameterResourceLocator, INodeParameters, IRunExecutionData, IWorkflowDataProxyAdditionalKeys, IWorkflowDataProxyData, NodeParameterValue, + NodeParameterValueType, Workflow, WorkflowDataProxy, WorkflowExecuteMode, @@ -351,14 +353,9 @@ export class Expression { timezone: string, additionalKeys: IWorkflowDataProxyAdditionalKeys, executeData?: IExecuteData, - defaultValue: - | NodeParameterValue - | INodeParameters - | NodeParameterValue[] - | INodeParameters[] - | undefined = undefined, + defaultValue: NodeParameterValueType | undefined = undefined, selfData = {}, - ): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | undefined { + ): NodeParameterValueType | undefined { if (parameterValue === undefined) { // Value is not set so return the default return defaultValue; @@ -423,7 +420,7 @@ export class Expression { * @memberof Workflow */ getParameterValue( - parameterValue: NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[], + parameterValue: NodeParameterValueType | INodeParameterResourceLocator, runExecutionData: IRunExecutionData | null, runIndex: number, itemIndex: number, @@ -435,17 +432,15 @@ export class Expression { executeData?: IExecuteData, returnObjectAsString = false, selfData = {}, - ): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] { + ): NodeParameterValueType { // Helper function which returns true when the parameter is a complex one or array - const isComplexParameter = ( - value: NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[], - ) => { + const isComplexParameter = (value: NodeParameterValueType) => { return typeof value === 'object'; }; // Helper function which resolves a parameter value depending on if it is simply or not const resolveParameterValue = ( - value: NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[], + value: NodeParameterValueType, siblingParameters: INodeParameters, ) => { if (isComplexParameter(value)) { @@ -519,7 +514,10 @@ export class Expression { const returnData: INodeParameters = {}; // eslint-disable-next-line no-restricted-syntax for (const [key, value] of Object.entries(parameterValue)) { - returnData[key] = resolveParameterValue(value, parameterValue); + returnData[key] = resolveParameterValue( + value as NodeParameterValueType, + parameterValue as INodeParameters, + ); } if (returnObjectAsString && typeof returnData === 'object') { diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 21786537e8982..6241e0f6922a9 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -543,12 +543,13 @@ export interface IN8nRequestOperationPaginationOffset extends IN8nRequestOperati }; } +export interface IGetNodeParameterOptions { + extractValue?: boolean; +} + export interface IExecuteFunctions { continueOnFail(): boolean; - evaluateExpression( - expression: string, - itemIndex: number, - ): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[]; + evaluateExpression(expression: string, itemIndex: number): NodeParameterValueType; executeWorkflow( workflowInfo: IExecuteWorkflowInfo, inputData?: INodeExecutionData[], @@ -567,7 +568,8 @@ export interface IExecuteFunctions { parameterName: string, itemIndex: number, fallbackValue?: any, - ): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | object; + options?: IGetNodeParameterOptions, + ): NodeParameterValueType | object; getWorkflowDataProxy(itemIndex: number): IWorkflowDataProxyData; getWorkflowStaticData(type: string): IDataObject; getRestApiUrl(): string; @@ -597,10 +599,7 @@ export interface IExecuteFunctions { export interface IExecuteSingleFunctions { continueOnFail(): boolean; - evaluateExpression( - expression: string, - itemIndex: number | undefined, - ): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[]; + evaluateExpression(expression: string, itemIndex: number | undefined): NodeParameterValueType; getContext(type: string): IContextObject; getCredentials(type: string): Promise; getInputData(inputIndex?: number, inputName?: string): INodeExecutionData; @@ -610,7 +609,8 @@ export interface IExecuteSingleFunctions { getNodeParameter( parameterName: string, fallbackValue?: any, - ): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | object; + options?: IGetNodeParameterOptions, + ): NodeParameterValueType | object; getRestApiUrl(): string; getTimezone(): string; getExecuteData(): IExecuteData; @@ -659,16 +659,9 @@ export interface ILoadOptionsFunctions { getNodeParameter( parameterName: string, fallbackValue?: any, - ): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | object; - getCurrentNodeParameter( - parameterName: string, - ): - | NodeParameterValue - | INodeParameters - | NodeParameterValue[] - | INodeParameters[] - | object - | undefined; + options?: IGetNodeParameterOptions, + ): NodeParameterValueType | object; + getCurrentNodeParameter(parameterName: string): NodeParameterValueType | object | undefined; getCurrentNodeParameters(): INodeParameters | undefined; getTimezone(): string; getRestApiUrl(): string; @@ -703,7 +696,8 @@ export interface IHookFunctions { getNodeParameter( parameterName: string, fallbackValue?: any, - ): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | object; + options?: IGetNodeParameterOptions, + ): NodeParameterValueType | object; getTimezone(): string; getWebhookDescription(name: string): IWebhookDescription | undefined; getWebhookName(): string; @@ -732,7 +726,8 @@ export interface IPollFunctions { getNodeParameter( parameterName: string, fallbackValue?: any, - ): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | object; + options?: IGetNodeParameterOptions, + ): NodeParameterValueType | object; getRestApiUrl(): string; getTimezone(): string; getWorkflow(): IWorkflowMetadata; @@ -765,7 +760,8 @@ export interface ITriggerFunctions { getNodeParameter( parameterName: string, fallbackValue?: any, - ): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | object; + options?: IGetNodeParameterOptions, + ): NodeParameterValueType | object; getRestApiUrl(): string; getTimezone(): string; getWorkflow(): IWorkflowMetadata; @@ -793,7 +789,8 @@ export interface IWebhookFunctions { getNodeParameter( parameterName: string, fallbackValue?: any, - ): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | object; + options?: IGetNodeParameterOptions, + ): NodeParameterValueType | object; getNodeWebhookUrl: (name: string) => string | undefined; getParamsData(): object; getQueryData(): object; @@ -898,12 +895,33 @@ export interface INodeExecuteFunctions { getExecuteWebhookFunctions: IGetExecuteWebhookFunctions; } -// The values a node property can have export type NodeParameterValue = string | number | boolean | undefined | null; -export interface INodeParameters { +export type ResourceLocatorModes = 'id' | 'url' | 'list' | string; +export interface IResourceLocatorResult { + name: string; + value: string; + url?: string; +} + +export interface INodeParameterResourceLocator { + mode: ResourceLocatorModes; + value: NodeParameterValue; + cachedResultName?: string; + cachedResultUrl?: string; +} + +export type NodeParameterValueType = // TODO: Later also has to be possible to add multiple ones with the name name. So array has to be possible - [key: string]: NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[]; + | NodeParameterValue + | INodeParameters + | INodeParameterResourceLocator + | NodeParameterValue[] + | INodeParameters[] + | INodeParameterResourceLocator[]; + +export interface INodeParameters { + [key: string]: NodeParameterValueType; } export type NodePropertyTypes = @@ -919,7 +937,8 @@ export type NodePropertyTypes = | 'number' | 'options' | 'string' - | 'credentialsSelect'; + | 'credentialsSelect' + | 'resourceLocator'; export type CodeAutocompleteTypes = 'function' | 'functionItem'; @@ -967,7 +986,7 @@ export interface INodeProperties { name: string; type: NodePropertyTypes; typeOptions?: INodePropertyTypeOptions; - default: NodeParameterValue | INodeParameters | INodeParameters[] | NodeParameterValue[]; + default: NodeParameterValueType; description?: string; hint?: string; displayOptions?: IDisplayOptions; @@ -980,7 +999,56 @@ export interface INodeProperties { credentialTypes?: Array< 'extends:oAuth2Api' | 'extends:oAuth1Api' | 'has:authenticate' | 'has:genericAuth' >; + extractValue?: INodePropertyValueExtractor; + modes?: INodePropertyMode[]; +} + +export interface INodePropertyModeTypeOptions { + searchListMethod?: string; // Supported by: options + searchFilterRequired?: boolean; + searchable?: boolean; } + +export interface INodePropertyMode { + displayName: string; + name: string; + type: 'string' | 'list'; + hint?: string; + validation?: Array< + INodePropertyModeValidation | { (this: IExecuteSingleFunctions, value: string): void } + >; + placeholder?: string; + url?: string; + extractValue?: INodePropertyValueExtractor; + initType?: string; + entryTypes?: { + [name: string]: { + selectable?: boolean; + hidden?: boolean; + queryable?: boolean; + data?: { + request?: IHttpRequestOptions; + output?: INodeRequestOutput; + }; + }; + }; + search?: INodePropertyRouting; + typeOptions?: INodePropertyModeTypeOptions; +} + +export interface INodePropertyModeValidation { + type: string; + properties: {}; +} + +export interface INodePropertyRegexValidation extends INodePropertyModeValidation { + type: 'regex'; + properties: { + regex: string; + errorMessage: string; + }; +} + export interface INodePropertyOptions { name: string; value: string | number | boolean; @@ -989,12 +1057,39 @@ export interface INodePropertyOptions { routing?: INodePropertyRouting; } +export interface INodeListSearchItems extends INodePropertyOptions { + icon?: string; + url?: string; +} + +export interface INodeListSearchResult { + results: INodeListSearchItems[]; + paginationToken?: unknown; +} + export interface INodePropertyCollection { displayName: string; name: string; values: INodeProperties[]; } +export interface INodePropertyValueExtractorBase { + type: string; +} + +export interface INodePropertyValueExtractorRegex extends INodePropertyValueExtractorBase { + type: 'regex'; + regex: string | RegExp; +} + +export interface INodePropertyValueExtractorFunction { + (this: IExecuteSingleFunctions, value: string | NodeParameterValue): + | Promise + | (string | NodeParameterValue); +} + +export type INodePropertyValueExtractor = INodePropertyValueExtractorRegex; + export interface IParameterDependencies { [key: string]: string[]; } @@ -1028,6 +1123,13 @@ export interface INodeType { loadOptions?: { [key: string]: (this: ILoadOptionsFunctions) => Promise; }; + listSearch?: { + [key: string]: ( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, + ) => Promise; + }; credentialTest?: { // Contains a group of functions that test credentials. [functionName: string]: ICredentialTestFunction; diff --git a/packages/workflow/src/NodeHelpers.ts b/packages/workflow/src/NodeHelpers.ts index 9b67b45572d0d..b1a46e7f71c7f 100644 --- a/packages/workflow/src/NodeHelpers.ts +++ b/packages/workflow/src/NodeHelpers.ts @@ -23,9 +23,13 @@ import { INodeExecutionData, INodeIssueObjectProperty, INodeIssues, + INodeParameterResourceLocator, INodeParameters, INodeProperties, INodePropertyCollection, + INodePropertyMode, + INodePropertyModeValidation, + INodePropertyRegexValidation, INodeType, INodeVersionedType, IParameterDependencies, @@ -849,7 +853,6 @@ export function getNodeParameters( } } } - return nodeParameters; } @@ -1125,6 +1128,37 @@ export function nodeIssuesToString(issues: INodeIssues, node?: INode): string[] return nodeIssues; } +/* + * Validates resource locator node parameters based on validation ruled defined in each parameter mode + * + */ +export const validateResourceLocatorParameter = ( + value: INodeParameterResourceLocator, + parameterMode: INodePropertyMode, +): string[] => { + const valueToValidate = value?.value?.toString() || ''; + if (valueToValidate.startsWith('=')) { + return []; + } + + const validationErrors: string[] = []; + // Each mode can have multiple validations specified + if (parameterMode.validation) { + for (const validation of parameterMode.validation) { + if (validation && (validation as INodePropertyModeValidation).type === 'regex') { + const regexValidation = validation as INodePropertyRegexValidation; + const regex = new RegExp(`^${regexValidation.properties.regex}$`); + + if (!regex.test(valueToValidate)) { + validationErrors.push(regexValidation.properties.errorMessage); + } + } + } + } + + return validationErrors; +}; + /** * Adds an issue if the parameter is not defined * @@ -1136,14 +1170,16 @@ export function nodeIssuesToString(issues: INodeIssues, node?: INode): string[] export function addToIssuesIfMissing( foundIssues: INodeIssues, nodeProperties: INodeProperties, - value: NodeParameterValue, + value: NodeParameterValue | INodeParameterResourceLocator, ) { // TODO: Check what it really has when undefined if ( (nodeProperties.type === 'string' && (value === '' || value === undefined)) || (nodeProperties.type === 'multiOptions' && Array.isArray(value) && value.length === 0) || (nodeProperties.type === 'dateTime' && value === undefined) || - (nodeProperties.type === 'options' && (value === '' || value === undefined)) + (nodeProperties.type === 'options' && (value === '' || value === undefined)) || + (nodeProperties.type === 'resourceLocator' && + (!value || (typeof value === 'object' && !value.value))) ) { // Parameter is required but empty if (foundIssues.parameters === undefined) { @@ -1176,6 +1212,10 @@ export function getParameterValueByPath( return get(nodeValues, path ? `${path}.${parameterName}` : parameterName); } +function isINodeParameterResourceLocator(value: unknown): value is INodeParameterResourceLocator { + return typeof value === 'object' && value !== null && 'value' in value && 'mode' in value; +} + /** * Returns all the issues with the given node-values * @@ -1192,11 +1232,9 @@ export function getParameterIssues( node: INode, ): INodeIssues { const foundIssues: INodeIssues = {}; - let value; - if (nodeProperties.required === true) { if (displayParameterPath(nodeValues, nodeProperties, path, node)) { - value = getParameterValueByPath(nodeValues, nodeProperties.name, path); + const value = getParameterValueByPath(nodeValues, nodeProperties.name, path); if ( // eslint-disable-next-line @typescript-eslint/prefer-optional-chain @@ -1216,6 +1254,28 @@ export function getParameterIssues( } } + if (nodeProperties.type === 'resourceLocator') { + if (displayParameterPath(nodeValues, nodeProperties, path, node)) { + const value = getParameterValueByPath(nodeValues, nodeProperties.name, path); + if (isINodeParameterResourceLocator(value)) { + const mode = nodeProperties.modes?.find((option) => option.name === value.mode); + if (mode) { + const errors = validateResourceLocatorParameter(value, mode); + errors.forEach((error) => { + if (foundIssues.parameters === undefined) { + foundIssues.parameters = {}; + } + if (foundIssues.parameters[nodeProperties.name] === undefined) { + foundIssues.parameters[nodeProperties.name] = []; + } + + foundIssues.parameters[nodeProperties.name].push(error); + }); + } + } + } + } + // Check if there are any child parameters if (nodeProperties.options === undefined) { // There are none so nothing else to check @@ -1251,7 +1311,11 @@ export function getParameterIssues( let propertyOptions: INodePropertyCollection; for (propertyOptions of nodeProperties.options as INodePropertyCollection[]) { // Check if the option got set and if not skip it - value = getParameterValueByPath(nodeValues, propertyOptions.name, basePath.slice(0, -1)); + const value = getParameterValueByPath( + nodeValues, + propertyOptions.name, + basePath.slice(0, -1), + ); if (value === undefined) { continue; } diff --git a/packages/workflow/src/RoutingNode.ts b/packages/workflow/src/RoutingNode.ts index c52bfd2d8444e..6cbc35ef20810 100644 --- a/packages/workflow/src/RoutingNode.ts +++ b/packages/workflow/src/RoutingNode.ts @@ -45,6 +45,7 @@ import { IN8nRequestOperations, INodeProperties, INodePropertyCollection, + NodeParameterValueType, PostReceiveAction, } from './Interfaces'; @@ -580,13 +581,13 @@ export class RoutingNode { } getParameterValue( - parameterValue: NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[], + parameterValue: NodeParameterValueType, itemIndex: number, runIndex: number, executeData: IExecuteData, additionalKeys?: IWorkflowDataProxyAdditionalKeys, returnObjectAsString = false, - ): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | string { + ): NodeParameterValueType { if ( typeof parameterValue === 'object' || (typeof parameterValue === 'string' && parameterValue.charAt(0) === '=') @@ -642,8 +643,15 @@ export class RoutingNode { if (nodeProperties.routing) { let parameterValue: string | undefined; if (basePath + nodeProperties.name && 'type' in nodeProperties) { + // Extract value if it has extractValue defined or if it's a + // resourceLocator component. Resource locators are likely to have extractors + // and we can't know if the mode has one unless we dig all the way in. + const shouldExtractValue = + nodeProperties.extractValue !== undefined || nodeProperties.type === 'resourceLocator'; parameterValue = executeSingleFunctions.getNodeParameter( basePath + nodeProperties.name, + undefined, + { extractValue: shouldExtractValue }, ) as string; } @@ -663,6 +671,7 @@ export class RoutingNode { { ...additionalKeys, $value: parameterValue }, false, ) as string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any (returnData.options as Record)[key] = propertyValue; } diff --git a/packages/workflow/src/Workflow.ts b/packages/workflow/src/Workflow.ts index 76bbd7ced9157..87687c24842ac 100644 --- a/packages/workflow/src/Workflow.ts +++ b/packages/workflow/src/Workflow.ts @@ -40,7 +40,6 @@ import { IWorkflowExecuteAdditionalData, IWorkflowSettings, NodeHelpers, - NodeParameterValue, ObservableObject, RoutingNode, WebhookSetupMethodNames, @@ -57,6 +56,7 @@ import { IObservableObject, IRun, IRunNodeResponse, + NodeParameterValueType, } from './Interfaces'; function dedupe(arr: T[]): T[] { @@ -437,10 +437,10 @@ export class Workflow { * @memberof Workflow */ renameNodeInExpressions( - parameterValue: NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[], + parameterValue: NodeParameterValueType, currentName: string, newName: string, - ): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] { + ): NodeParameterValueType { if (typeof parameterValue !== 'object') { // Reached the actual value if (typeof parameterValue === 'string' && parameterValue.charAt(0) === '=') { @@ -503,7 +503,7 @@ export class Workflow { for (const parameterName of Object.keys(parameterValue || {})) { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access returnData[parameterName] = this.renameNodeInExpressions( - parameterValue![parameterName], + parameterValue![parameterName as keyof typeof parameterValue], currentName, newName, ); diff --git a/packages/workflow/src/WorkflowDataProxy.ts b/packages/workflow/src/WorkflowDataProxy.ts index fada3679497cb..29c4a0e351199 100644 --- a/packages/workflow/src/WorkflowDataProxy.ts +++ b/packages/workflow/src/WorkflowDataProxy.ts @@ -24,7 +24,7 @@ import { IWorkflowDataProxyAdditionalKeys, IWorkflowDataProxyData, NodeHelpers, - NodeParameterValue, + NodeParameterValueType, Workflow, WorkflowExecuteMode, } from '.'; @@ -178,11 +178,7 @@ export class WorkflowDataProxy { get(target, name, receiver) { name = name.toString(); - let returnValue: - | INodeParameters - | NodeParameterValue - | NodeParameterValue[] - | INodeParameters[]; + let returnValue: NodeParameterValueType; if (name[0] === '&') { const key = name.slice(1); if (!that.siblingParameters.hasOwnProperty(key)) { diff --git a/packages/workflow/test/RoutingNode.test.ts b/packages/workflow/test/RoutingNode.test.ts index 95558504a64ae..e8fbaa80d9c07 100644 --- a/packages/workflow/test/RoutingNode.test.ts +++ b/packages/workflow/test/RoutingNode.test.ts @@ -632,7 +632,7 @@ describe('RoutingNode', () => { }; for (const testData of tests) { - test(testData.description, () => { + test(testData.description, async () => { node.parameters = testData.input.nodeParameters; nodeType.description.properties = [testData.input.nodeTypeProperties]; @@ -669,7 +669,7 @@ describe('RoutingNode', () => { mode, ); - const result = routingNode.getRequestOptionsFromParameters( + const result = await routingNode.getRequestOptionsFromParameters( executeSingleFunctions, testData.input.nodeTypeProperties, itemIndex,