From 8474cd386ddfca9e9078b45af65af9299d63eb85 Mon Sep 17 00:00:00 2001 From: Michael Auerswald Date: Thu, 13 Apr 2023 10:09:50 +0200 Subject: [PATCH 01/30] fix(core): Skip SAML onboarding for users with first- and lastname (#5966) skip onboarding for users with first- and lastname --- packages/cli/src/sso/saml/routes/saml.controller.ee.ts | 2 +- packages/cli/src/sso/saml/saml.service.ee.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/sso/saml/routes/saml.controller.ee.ts b/packages/cli/src/sso/saml/routes/saml.controller.ee.ts index 731d67f3753df..1dfe6ded824d6 100644 --- a/packages/cli/src/sso/saml/routes/saml.controller.ee.ts +++ b/packages/cli/src/sso/saml/routes/saml.controller.ee.ts @@ -104,7 +104,7 @@ export class SamlController { private async acsHandler(req: express.Request, res: express.Response, binding: SamlLoginBinding) { const loginResult = await this.samlService.handleSamlLogin(req, binding); if (loginResult) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + // return attributes if this is a test connection if (req.body.RelayState && req.body.RelayState === getServiceProviderConfigTestReturnUrl()) { return res.status(202).send(loginResult.attributes); } diff --git a/packages/cli/src/sso/saml/saml.service.ee.ts b/packages/cli/src/sso/saml/saml.service.ee.ts index 7607a71ee3f40..22909f46c9d71 100644 --- a/packages/cli/src/sso/saml/saml.service.ee.ts +++ b/packages/cli/src/sso/saml/saml.service.ee.ts @@ -154,7 +154,7 @@ export class SamlService { relations: ['globalRole', 'authIdentities'], }); if (user) { - // Login path for existing users that are fully set up + // Login path for existing users that are fully set up and that have a SAML authIdentity set up if ( user.authIdentities.find( (e) => e.providerType === 'saml' && e.providerId === attributes.userPrincipalName, @@ -168,10 +168,11 @@ export class SamlService { } else { // Login path for existing users that are NOT fully set up for SAML const updatedUser = await updateUserFromSamlAttributes(user, attributes); + const onboardingRequired = !updatedUser.firstName || !updatedUser.lastName; return { authenticatedUser: updatedUser, attributes, - onboardingRequired: true, + onboardingRequired, }; } } else { From f8f8374506c3d0c2ad7cea73bb461b3f64a81be1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 13 Apr 2023 14:14:27 +0200 Subject: [PATCH 02/30] feat(editor): Add Ask AI preview (#5916) * :sparkles: Add Ask AI preview * :bug: Fire event on mousedown * :zap: Update to use Alex's event bus * :pencil2: Use i18n * :zap: Add telemetry * :recycle: Change trigger from focus to hover * :zap: Ensure focus + hover trigger event --- .../src/components/N8nButton/Button.vue | 2 +- .../editor-ui/src/components/AskAiModal.vue | 52 +++++++++++++++ .../CodeNodeEditor/CodeNodeEditor.vue | 65 ++++++++++++++++++- packages/editor-ui/src/components/Modals.vue | 8 +++ packages/editor-ui/src/constants.ts | 2 + .../src/plugins/i18n/locales/en.json | 4 ++ packages/editor-ui/src/stores/ui.ts | 4 ++ 7 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 packages/editor-ui/src/components/AskAiModal.vue diff --git a/packages/design-system/src/components/N8nButton/Button.vue b/packages/design-system/src/components/N8nButton/Button.vue index e0d6d8334dcbb..127a1abd5ff3e 100644 --- a/packages/design-system/src/components/N8nButton/Button.vue +++ b/packages/design-system/src/components/N8nButton/Button.vue @@ -194,7 +194,7 @@ $loading-overlay-background-color: rgba(255, 255, 255, 0); } .tertiary { - font-weight: var(--font-weight-regular) !important; + font-weight: var(--font-weight-bold) !important; --button-background-color: var(--color-background-xlight); --button-color: var(--color-text-dark); diff --git a/packages/editor-ui/src/components/AskAiModal.vue b/packages/editor-ui/src/components/AskAiModal.vue new file mode 100644 index 0000000000000..1a678394a9854 --- /dev/null +++ b/packages/editor-ui/src/components/AskAiModal.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue b/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue index 55c597bcce4b8..fff8c461fa446 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue +++ b/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue @@ -1,5 +1,21 @@ - + diff --git a/packages/editor-ui/src/components/Modals.vue b/packages/editor-ui/src/components/Modals.vue index 8b26f5dce6b22..f9866042bd54d 100644 --- a/packages/editor-ui/src/components/Modals.vue +++ b/packages/editor-ui/src/components/Modals.vue @@ -15,6 +15,10 @@ + + + + @@ -138,10 +142,12 @@ import { WORKFLOW_SHARE_MODAL_KEY, IMPORT_CURL_MODAL_KEY, LOG_STREAM_MODAL_KEY, + ASK_AI_MODAL_KEY, USER_ACTIVATION_SURVEY_MODAL, } from '@/constants'; import AboutModal from './AboutModal.vue'; +import AskAiModal from './AskAiModal.vue'; import CommunityPackageManageConfirmModal from './CommunityPackageManageConfirmModal.vue'; import CommunityPackageInstallModal from './CommunityPackageInstallModal.vue'; import ChangePasswordModal from './ChangePasswordModal.vue'; @@ -169,6 +175,7 @@ export default Vue.extend({ name: 'Modals', components: { AboutModal, + AskAiModal, ActivationModal, CommunityPackageInstallModal, CommunityPackageManageConfirmModal, @@ -199,6 +206,7 @@ export default Vue.extend({ CREDENTIAL_EDIT_MODAL_KEY, CREDENTIAL_SELECT_MODAL_KEY, ABOUT_MODAL_KEY, + ASK_AI_MODAL_KEY, CHANGE_PASSWORD_MODAL_KEY, DELETE_USER_MODAL_KEY, DUPLICATE_MODAL_KEY, diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index 7504d6d4e736f..1dd90ccbada3b 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -26,6 +26,7 @@ export const MAX_TAG_NAME_LENGTH = 24; // modals export const ABOUT_MODAL_KEY = 'about'; +export const ASK_AI_MODAL_KEY = 'askAi'; export const CHANGE_PASSWORD_MODAL_KEY = 'changePassword'; export const CREDENTIAL_EDIT_MODAL_KEY = 'editCredential'; export const CREDENTIAL_SELECT_MODAL_KEY = 'selectCredential'; @@ -193,6 +194,7 @@ export const FLOWS_CONTROL_SUBCATEGORY = 'Flow'; export const HELPERS_SUBCATEGORY = 'Helpers'; export const REQUEST_NODE_FORM_URL = 'https://n8n-community.typeform.com/to/K1fBVTZ3'; +export const ASK_AI_WAITLIST_URL = 'https://n8n-community.typeform.com/to/odKU4oDR'; // General export const INSTANCE_ID_HEADER = 'n8n-instance-id'; diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index c20068e32183a..62bce2e86a450 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -49,6 +49,9 @@ "about.n8nVersion": "n8n Version", "about.sourceCode": "Source Code", "about.instanceID": "Instance ID", + "askAi.dialog.title": "'Ask AI' is almost ready", + "askAi.dialog.body": "We’re still applying the finishing touches. Soon, you will be able to automatically generate code from simple text prompts. Join the waitlist to get early access to this feature.", + "askAi.dialog.signup": "Join Waitlist", "activationModal.butYouCanSeeThem": "but you can see them in the", "activationModal.dontShowAgain": "Don't show again", "activationModal.executionList": "execution list", @@ -110,6 +113,7 @@ "binaryDataDisplay.noDataFoundToDisplay": "No data found to display", "binaryDataDisplay.yourBrowserDoesNotSupport": "Your browser does not support the video element. Kindly update it to latest version.", "codeEdit.edit": "Edit", + "codeNodeEditor.askAi": "✨ Ask AI", "codeNodeEditor.completer.$()": "Output data of the {nodeName} node", "codeNodeEditor.completer.$execution": "Information about the current execution", "codeNodeEditor.completer.$execution.id": "The ID of the current execution", diff --git a/packages/editor-ui/src/stores/ui.ts b/packages/editor-ui/src/stores/ui.ts index 3b8c6d0d08a22..666dc7af5bad2 100644 --- a/packages/editor-ui/src/stores/ui.ts +++ b/packages/editor-ui/src/stores/ui.ts @@ -5,6 +5,7 @@ import { } from '@/api/workflow-webhooks'; import { ABOUT_MODAL_KEY, + ASK_AI_MODAL_KEY, CHANGE_PASSWORD_MODAL_KEY, COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY, COMMUNITY_PACKAGE_INSTALL_MODAL_KEY, @@ -56,6 +57,9 @@ export const useUIStore = defineStore(STORES.UI, { [ABOUT_MODAL_KEY]: { open: false, }, + [ASK_AI_MODAL_KEY]: { + open: false, + }, [CHANGE_PASSWORD_MODAL_KEY]: { open: false, }, From 1a8a9f8ddbf248752e04284f18891b40a3039201 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 13 Apr 2023 14:14:55 +0200 Subject: [PATCH 03/30] fix(core): Fix lint on SAML controller (no-changelog) (#5967) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :shirt: Fix lint on SAML controller * :blue_book: Type request properly * Update requests.ts * :truck: Move type to module Assuming ACS falls under configuration. --------- Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ --- .../cli/src/sso/saml/routes/saml.controller.ee.ts | 10 +++++++--- packages/cli/src/sso/saml/types/requests.ts | 12 +++++++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/sso/saml/routes/saml.controller.ee.ts b/packages/cli/src/sso/saml/routes/saml.controller.ee.ts index 1dfe6ded824d6..7ced030b0f579 100644 --- a/packages/cli/src/sso/saml/routes/saml.controller.ee.ts +++ b/packages/cli/src/sso/saml/routes/saml.controller.ee.ts @@ -83,7 +83,7 @@ export class SamlController { * Assertion Consumer Service endpoint */ @Get(SamlUrls.acs, { middlewares: [samlLicensedMiddleware] }) - async acsGet(req: express.Request, res: express.Response) { + async acsGet(req: SamlConfiguration.AcsRequest, res: express.Response) { return this.acsHandler(req, res, 'redirect'); } @@ -92,7 +92,7 @@ export class SamlController { * Assertion Consumer Service endpoint */ @Post(SamlUrls.acs, { middlewares: [samlLicensedMiddleware] }) - async acsPost(req: express.Request, res: express.Response) { + async acsPost(req: SamlConfiguration.AcsRequest, res: express.Response) { return this.acsHandler(req, res, 'post'); } @@ -101,7 +101,11 @@ export class SamlController { * Available if SAML is licensed, even if not enabled to run connection tests * For test connections, returns status 202 if SAML is not enabled */ - private async acsHandler(req: express.Request, res: express.Response, binding: SamlLoginBinding) { + private async acsHandler( + req: SamlConfiguration.AcsRequest, + res: express.Response, + binding: SamlLoginBinding, + ) { const loginResult = await this.samlService.handleSamlLogin(req, binding); if (loginResult) { // return attributes if this is a test connection diff --git a/packages/cli/src/sso/saml/types/requests.ts b/packages/cli/src/sso/saml/types/requests.ts index 4d58afb2dd53d..3d3e4125fc8d8 100644 --- a/packages/cli/src/sso/saml/types/requests.ts +++ b/packages/cli/src/sso/saml/types/requests.ts @@ -1,7 +1,17 @@ -import type { AuthenticatedRequest } from '@/requests'; +import type { AuthenticatedRequest, AuthlessRequest } from '@/requests'; import type { SamlPreferences } from './samlPreferences'; export declare namespace SamlConfiguration { type Update = AuthenticatedRequest<{}, {}, SamlPreferences, {}>; type Toggle = AuthenticatedRequest<{}, {}, { loginEnabled: boolean }, {}>; + + type AcsRequest = AuthlessRequest< + {}, + {}, + { + // eslint-disable-next-line @typescript-eslint/naming-convention + RelayState?: string; + }, + {} + >; } From bc1db5e16a81aced70a25de6f7a06f6cda54622a Mon Sep 17 00:00:00 2001 From: Csaba Tuncsik Date: Thu, 13 Apr 2023 16:17:47 +0200 Subject: [PATCH 04/30] test(editor): SSO tests (#5946) * test(editor): SSO tests * test(editor): move store tests to __tests__ folder * test(editor): move tests in a different PR * test(editor): add SSO tests * test(editor): add SSO settings page tests * test(editor): add SSO onboarding page base test * test(editor): add SSO onboarding page test * test(editor): fix router spy --- .../__tests__/ExecutionsList.test.ts | 3 +- .../src/components/__tests__/SSOLogin.test.ts | 59 +++++++++ .../src/stores/__tests__/sso.test.ts | 48 ++++++++ packages/editor-ui/src/stores/sso.ts | 1 + .../src/utils/__tests__/userUtils.test.ts | 80 ++---------- packages/editor-ui/src/utils/testUtils.ts | 103 ++++++++++++++++ packages/editor-ui/src/views/SettingsSso.vue | 14 ++- .../src/views/__tests__/AuthView.test.ts | 49 ++++++++ .../views/__tests__/SamlOnboarding.test.ts | 82 +++++++++++++ .../src/views/__tests__/SettingsSso.test.ts | 115 ++++++++++++++++++ 10 files changed, 475 insertions(+), 79 deletions(-) create mode 100644 packages/editor-ui/src/components/__tests__/SSOLogin.test.ts create mode 100644 packages/editor-ui/src/stores/__tests__/sso.test.ts create mode 100644 packages/editor-ui/src/utils/testUtils.ts create mode 100644 packages/editor-ui/src/views/__tests__/AuthView.test.ts create mode 100644 packages/editor-ui/src/views/__tests__/SamlOnboarding.test.ts create mode 100644 packages/editor-ui/src/views/__tests__/SettingsSso.test.ts diff --git a/packages/editor-ui/src/components/__tests__/ExecutionsList.test.ts b/packages/editor-ui/src/components/__tests__/ExecutionsList.test.ts index 78ffd048575c2..09eaa92ad2368 100644 --- a/packages/editor-ui/src/components/__tests__/ExecutionsList.test.ts +++ b/packages/editor-ui/src/components/__tests__/ExecutionsList.test.ts @@ -14,8 +14,7 @@ import { showMessage } from '@/mixins/showMessage'; import { i18nInstance } from '@/plugins/i18n'; import type { IWorkflowShortResponse } from '@/Interface'; import type { IExecutionsSummary } from 'n8n-workflow'; - -const waitAllPromises = () => new Promise((resolve) => setTimeout(resolve)); +import { waitAllPromises } from '@/utils/testUtils'; const workflowDataFactory = (): IWorkflowShortResponse => ({ createdAt: faker.date.past().toDateString(), diff --git a/packages/editor-ui/src/components/__tests__/SSOLogin.test.ts b/packages/editor-ui/src/components/__tests__/SSOLogin.test.ts new file mode 100644 index 0000000000000..0ee32a747c17b --- /dev/null +++ b/packages/editor-ui/src/components/__tests__/SSOLogin.test.ts @@ -0,0 +1,59 @@ +import { PiniaVuePlugin } from 'pinia'; +import { render } from '@testing-library/vue'; +import { createTestingPinia } from '@pinia/testing'; +import { merge } from 'lodash-es'; +import SSOLogin from '@/components/SSOLogin.vue'; +import { STORES } from '@/constants'; +import { useSSOStore } from '@/stores/sso'; +import { SETTINGS_STORE_DEFAULT_STATE } from '@/utils/testUtils'; +import { afterEach } from 'vitest'; + +let pinia: ReturnType; +let ssoStore: ReturnType; + +const renderComponent = (renderOptions: Parameters[1] = {}) => + render( + SSOLogin, + merge( + { + pinia, + stubs: { + 'n8n-button': { + template: '', + }, + }, + }, + renderOptions, + ), + (vue) => { + vue.use(PiniaVuePlugin); + }, + ); + +describe('SSOLogin', () => { + beforeEach(() => { + pinia = createTestingPinia({ + initialState: { + [STORES.SETTINGS]: { + settings: merge({}, SETTINGS_STORE_DEFAULT_STATE.settings), + }, + }, + }); + ssoStore = useSSOStore(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should not render button if conditions are not met', () => { + const { queryByRole } = renderComponent(); + expect(queryByRole('button')).not.toBeInTheDocument(); + }); + + it('should render button if the store returns true for the conditions', () => { + vi.spyOn(ssoStore, 'showSsoLoginButton', 'get').mockReturnValue(true); + const { queryByRole } = renderComponent(); + expect(queryByRole('button')).toBeInTheDocument(); + }); +}); diff --git a/packages/editor-ui/src/stores/__tests__/sso.test.ts b/packages/editor-ui/src/stores/__tests__/sso.test.ts new file mode 100644 index 0000000000000..3d79f07037f60 --- /dev/null +++ b/packages/editor-ui/src/stores/__tests__/sso.test.ts @@ -0,0 +1,48 @@ +import { createPinia, setActivePinia } from 'pinia'; +import { useSettingsStore } from '@/stores/settings'; +import { useSSOStore } from '@/stores/sso'; +import { merge } from 'lodash-es'; +import { IN8nUISettings } from '@/Interface'; +import { SETTINGS_STORE_DEFAULT_STATE } from '@/utils/testUtils'; + +let ssoStore: ReturnType; +let settingsStore: ReturnType; + +const DEFAULT_SETTINGS: IN8nUISettings = SETTINGS_STORE_DEFAULT_STATE.settings; + +describe('SSO store', () => { + beforeEach(() => { + setActivePinia(createPinia()); + ssoStore = useSSOStore(); + settingsStore = useSettingsStore(); + }); + + test.each([ + ['saml', true, true, true], + ['saml', false, true, false], + ['saml', false, false, false], + ['saml', true, false, false], + ['email', true, true, false], + ])( + 'should check SSO login button availability when authenticationMethod is %s and enterprise feature is %s and sso login is set to %s', + (authenticationMethod, saml, loginEnabled, expectation) => { + settingsStore.setSettings( + merge({}, DEFAULT_SETTINGS, { + userManagement: { + authenticationMethod, + }, + enterprise: { + saml, + }, + sso: { + saml: { + loginEnabled, + }, + }, + }), + ); + + expect(ssoStore.showSsoLoginButton).toBe(expectation); + }, + ); +}); diff --git a/packages/editor-ui/src/stores/sso.ts b/packages/editor-ui/src/stores/sso.ts index f1951ab54c185..3de019a01a1ad 100644 --- a/packages/editor-ui/src/stores/sso.ts +++ b/packages/editor-ui/src/stores/sso.ts @@ -75,6 +75,7 @@ export const useSSOStore = defineStore('sso', () => { setLoading, isSamlLoginEnabled, isEnterpriseSamlEnabled, + isDefaultAuthenticationSaml, showSsoLoginButton, getSSORedirectUrl, getSamlMetadata, diff --git a/packages/editor-ui/src/utils/__tests__/userUtils.test.ts b/packages/editor-ui/src/utils/__tests__/userUtils.test.ts index e2fb83d3a8557..d9864b09b4438 100644 --- a/packages/editor-ui/src/utils/__tests__/userUtils.test.ts +++ b/packages/editor-ui/src/utils/__tests__/userUtils.test.ts @@ -1,76 +1,15 @@ import { beforeAll } from 'vitest'; import { setActivePinia, createPinia } from 'pinia'; +import { merge } from 'lodash-es'; import { isAuthorized } from '@/utils'; import { useSettingsStore } from '@/stores/settings'; import { useSSOStore } from '@/stores/sso'; -import { IN8nUISettings, IUser, UserManagementAuthenticationMethod } from '@/Interface'; +import { IN8nUISettings, IUser } from '@/Interface'; import { routes } from '@/router'; import { VIEWS } from '@/constants'; +import { SETTINGS_STORE_DEFAULT_STATE } from '@/utils/testUtils'; -const DEFAULT_SETTINGS: IN8nUISettings = { - allowedModules: {}, - communityNodesEnabled: false, - defaultLocale: '', - endpointWebhook: '', - endpointWebhookTest: '', - enterprise: { - advancedExecutionFilters: false, - sharing: false, - ldap: false, - saml: false, - logStreaming: false, - }, - executionMode: '', - executionTimeout: 0, - hideUsagePage: false, - hiringBannerEnabled: false, - instanceId: '', - isNpmAvailable: false, - license: { environment: 'production' }, - logLevel: 'info', - maxExecutionTimeout: 0, - oauthCallbackUrls: { oauth1: '', oauth2: '' }, - onboardingCallPromptEnabled: false, - personalizationSurveyEnabled: false, - posthog: { - apiHost: '', - apiKey: '', - autocapture: false, - debug: false, - disableSessionRecording: false, - enabled: false, - }, - publicApi: { enabled: false, latestVersion: 0, path: '', swaggerUi: { enabled: false } }, - pushBackend: 'sse', - saveDataErrorExecution: '', - saveDataSuccessExecution: '', - saveManualExecutions: false, - sso: { - ldap: { loginEnabled: false, loginLabel: '' }, - saml: { loginEnabled: false, loginLabel: '' }, - }, - telemetry: { enabled: false }, - templates: { enabled: false, host: '' }, - timezone: '', - urlBaseEditor: '', - urlBaseWebhook: '', - userManagement: { - enabled: false, - smtpSetup: false, - authenticationMethod: UserManagementAuthenticationMethod.Email, - }, - versionCli: '', - versionNotifications: { - enabled: false, - endpoint: '', - infoUrl: '', - }, - workflowCallerPolicyDefaultOption: 'any', - workflowTagsDisabled: false, - deployment: { - type: 'default', - }, -}; +const DEFAULT_SETTINGS: IN8nUISettings = SETTINGS_STORE_DEFAULT_STATE.settings; const DEFAULT_USER: IUser = { id: '1', @@ -101,23 +40,18 @@ describe('userUtils', () => { .find((route) => route.path.startsWith('/settings')) ?.children?.find((route) => route.name === VIEWS.SSO_SETTINGS)?.meta?.permissions; - const user: IUser = { - ...DEFAULT_USER, + const user: IUser = merge({}, DEFAULT_USER, { isDefaultUser: false, isOwner: true, globalRole: { - ...DEFAULT_USER.globalRole, id: '1', name: 'owner', createdAt: new Date(), }, - }; - - settingsStore.setSettings({ - ...DEFAULT_SETTINGS, - enterprise: { ...DEFAULT_SETTINGS.enterprise, saml: true }, }); + settingsStore.setSettings(merge({}, DEFAULT_SETTINGS, { enterprise: { saml: true } })); + expect(isAuthorized(ssoSettingsPermissions, user)).toBe(true); }); }); diff --git a/packages/editor-ui/src/utils/testUtils.ts b/packages/editor-ui/src/utils/testUtils.ts new file mode 100644 index 0000000000000..48f11a2c15f79 --- /dev/null +++ b/packages/editor-ui/src/utils/testUtils.ts @@ -0,0 +1,103 @@ +import { ISettingsState, UserManagementAuthenticationMethod } from '@/Interface'; + +export const waitAllPromises = () => new Promise((resolve) => setTimeout(resolve)); + +export const SETTINGS_STORE_DEFAULT_STATE: ISettingsState = { + settings: { + allowedModules: {}, + communityNodesEnabled: false, + defaultLocale: '', + endpointWebhook: '', + endpointWebhookTest: '', + enterprise: { + advancedExecutionFilters: false, + sharing: false, + ldap: false, + saml: false, + logStreaming: false, + }, + executionMode: '', + executionTimeout: 0, + hideUsagePage: false, + hiringBannerEnabled: false, + instanceId: '', + isNpmAvailable: false, + license: { environment: 'production' }, + logLevel: 'info', + maxExecutionTimeout: 0, + oauthCallbackUrls: { oauth1: '', oauth2: '' }, + onboardingCallPromptEnabled: false, + personalizationSurveyEnabled: false, + posthog: { + apiHost: '', + apiKey: '', + autocapture: false, + debug: false, + disableSessionRecording: false, + enabled: false, + }, + publicApi: { enabled: false, latestVersion: 0, path: '', swaggerUi: { enabled: false } }, + pushBackend: 'sse', + saveDataErrorExecution: '', + saveDataSuccessExecution: '', + saveManualExecutions: false, + sso: { + ldap: { loginEnabled: false, loginLabel: '' }, + saml: { loginEnabled: false, loginLabel: '' }, + }, + telemetry: { enabled: false }, + templates: { enabled: false, host: '' }, + timezone: '', + urlBaseEditor: '', + urlBaseWebhook: '', + userManagement: { + enabled: false, + smtpSetup: false, + authenticationMethod: UserManagementAuthenticationMethod.Email, + }, + versionCli: '', + versionNotifications: { + enabled: false, + endpoint: '', + infoUrl: '', + }, + workflowCallerPolicyDefaultOption: 'any', + workflowTagsDisabled: false, + deployment: { + type: 'default', + }, + }, + promptsData: { + message: '', + title: '', + showContactPrompt: false, + showValueSurvey: false, + }, + userManagement: { + enabled: false, + showSetupOnFirstLoad: false, + smtpSetup: false, + authenticationMethod: UserManagementAuthenticationMethod.Email, + }, + templatesEndpointHealthy: false, + api: { + enabled: false, + latestVersion: 0, + path: '/', + swaggerUi: { + enabled: false, + }, + }, + ldap: { + loginLabel: '', + loginEnabled: false, + }, + saml: { + loginLabel: '', + loginEnabled: false, + }, + onboardingCallPromptEnabled: false, + saveDataErrorExecution: 'all', + saveDataSuccessExecution: 'all', + saveManualExecutions: false, +}; diff --git a/packages/editor-ui/src/views/SettingsSso.vue b/packages/editor-ui/src/views/SettingsSso.vue index 3e1c516291e17..cd58958f386f4 100644 --- a/packages/editor-ui/src/views/SettingsSso.vue +++ b/packages/editor-ui/src/views/SettingsSso.vue @@ -112,7 +112,7 @@ onBeforeMount(async () => { -
+
{
- + {{ locale.baseText('settings.sso.settings.ips.help') }}
- + {{ locale.baseText('settings.sso.settings.test') }} - + {{ locale.baseText('settings.sso.settings.save') }}
[1] = {}) => + render( + AuthView, + merge( + { + pinia: createTestingPinia(), + stubs: { + SSOLogin: { + template: '
', + }, + }, + }, + renderOptions, + ), + (vue) => { + vue.use(PiniaVuePlugin); + }, + ); + +describe('AuthView', () => { + it('should render with subtitle', () => { + const { getByText } = renderComponent({ + props: { + subtitle: 'Some text', + }, + }); + expect(getByText('Some text')).toBeInTheDocument(); + }); + + it('should render without SSO component', () => { + const { queryByTestId } = renderComponent(); + expect(queryByTestId('sso-login')).not.toBeInTheDocument(); + }); + + it('should render with SSO component', () => { + const { getByTestId } = renderComponent({ + props: { + withSso: true, + }, + }); + expect(getByTestId('sso-login')).toBeInTheDocument(); + }); +}); diff --git a/packages/editor-ui/src/views/__tests__/SamlOnboarding.test.ts b/packages/editor-ui/src/views/__tests__/SamlOnboarding.test.ts new file mode 100644 index 0000000000000..524144b160aff --- /dev/null +++ b/packages/editor-ui/src/views/__tests__/SamlOnboarding.test.ts @@ -0,0 +1,82 @@ +import { PiniaVuePlugin } from 'pinia'; +import { render } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; +import { useRouter } from 'vue-router/composables'; +import { createTestingPinia } from '@pinia/testing'; +import { merge } from 'lodash-es'; +import SamlOnboarding from '@/views/SamlOnboarding.vue'; +import { useSSOStore } from '@/stores/sso'; +import { STORES } from '@/constants'; +import { SETTINGS_STORE_DEFAULT_STATE, waitAllPromises } from '@/utils/testUtils'; +import { i18nInstance } from '@/plugins/i18n'; + +vi.mock('vue-router/composables', () => { + const push = vi.fn(); + return { + useRouter: () => ({ + push, + }), + }; +}); + +let pinia: ReturnType; +let ssoStore: ReturnType; +let router: ReturnType; + +const renderComponent = (renderOptions: Parameters[1] = {}) => + render( + SamlOnboarding, + merge( + { + pinia, + i18n: i18nInstance, + }, + renderOptions, + ), + (vue) => { + vue.use(PiniaVuePlugin); + }, + ); + +describe('SamlOnboarding', () => { + beforeEach(() => { + pinia = createTestingPinia({ + initialState: { + [STORES.SETTINGS]: { + settings: merge({}, SETTINGS_STORE_DEFAULT_STATE.settings), + }, + }, + }); + ssoStore = useSSOStore(pinia); + router = useRouter(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should submit filled in form only and redirect', async () => { + vi.spyOn(ssoStore, 'updateUser').mockResolvedValue({ + id: '1', + isPending: false, + }); + + const { getByRole, getAllByRole } = renderComponent(); + + const inputs = getAllByRole('textbox'); + const submit = getByRole('button'); + + await userEvent.click(submit); + await waitAllPromises(); + + expect(ssoStore.updateUser).not.toHaveBeenCalled(); + expect(router.push).not.toHaveBeenCalled(); + + await userEvent.type(inputs[0], 'test'); + await userEvent.type(inputs[1], 'test'); + await userEvent.click(submit); + + expect(ssoStore.updateUser).toHaveBeenCalled(); + expect(router.push).toHaveBeenCalled(); + }); +}); diff --git a/packages/editor-ui/src/views/__tests__/SettingsSso.test.ts b/packages/editor-ui/src/views/__tests__/SettingsSso.test.ts new file mode 100644 index 0000000000000..1da485a4c2bd7 --- /dev/null +++ b/packages/editor-ui/src/views/__tests__/SettingsSso.test.ts @@ -0,0 +1,115 @@ +import { PiniaVuePlugin } from 'pinia'; +import { render } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; +import { createTestingPinia } from '@pinia/testing'; +import { merge } from 'lodash-es'; +import { faker } from '@faker-js/faker'; +import SettingsSso from '@/views/SettingsSso.vue'; +import { useSSOStore } from '@/stores/sso'; +import { STORES } from '@/constants'; +import { SETTINGS_STORE_DEFAULT_STATE, waitAllPromises } from '@/utils/testUtils'; +import { i18nInstance } from '@/plugins/i18n'; +import { SamlPreferences, SamlPreferencesExtractedData } from '@/Interface'; + +let pinia: ReturnType; +let ssoStore: ReturnType; + +const samlConfig: SamlPreferences & SamlPreferencesExtractedData = { + metadata: '', + entityID: faker.internet.url(), + returnUrl: faker.internet.url(), +}; + +const renderComponent = (renderOptions: Parameters[1] = {}) => + render( + SettingsSso, + merge( + { + pinia, + i18n: i18nInstance, + }, + renderOptions, + ), + (vue) => { + vue.use(PiniaVuePlugin); + }, + ); + +describe('SettingsSso', () => { + beforeEach(() => { + pinia = createTestingPinia({ + initialState: { + [STORES.SETTINGS]: { + settings: merge({}, SETTINGS_STORE_DEFAULT_STATE.settings), + }, + }, + }); + ssoStore = useSSOStore(pinia); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should render paywall state when there is no license', () => { + const { getByTestId, queryByTestId, queryByRole } = renderComponent(); + + expect(queryByRole('checkbox')).not.toBeInTheDocument(); + expect(queryByTestId('sso-content-licensed')).not.toBeInTheDocument(); + expect(getByTestId('sso-content-unlicensed')).toBeInTheDocument(); + }); + + it('should render licensed content', () => { + vi.spyOn(ssoStore, 'isEnterpriseSamlEnabled', 'get').mockReturnValue(true); + + const { getByTestId, queryByTestId, getByRole } = renderComponent(); + + expect(getByRole('checkbox')).toBeInTheDocument(); + expect(getByTestId('sso-content-licensed')).toBeInTheDocument(); + expect(queryByTestId('sso-content-unlicensed')).not.toBeInTheDocument(); + }); + + it('should enable activation checkbox and test button if data is already saved', async () => { + vi.spyOn(ssoStore, 'isEnterpriseSamlEnabled', 'get').mockReturnValue(true); + vi.spyOn(ssoStore, 'getSamlConfig').mockResolvedValue(samlConfig); + + const { getByRole, getByTestId } = renderComponent(); + await waitAllPromises(); + + expect(getByRole('checkbox')).toBeEnabled(); + expect(getByTestId('sso-test')).toBeEnabled(); + }); + + it('should enable activation checkbox after data is saved', async () => { + vi.spyOn(ssoStore, 'isEnterpriseSamlEnabled', 'get').mockReturnValue(true); + + const { getByRole, getAllByRole, getByTestId } = renderComponent(); + const checkbox = getByRole('checkbox'); + const btnSave = getByTestId('sso-save'); + const btnTest = getByTestId('sso-test'); + + expect(checkbox).toBeDisabled(); + [btnSave, btnTest].forEach((el) => { + expect(el).toBeDisabled(); + }); + + await userEvent.type( + getAllByRole('textbox').find((el) => el.getAttribute('name') === 'metadata')!, + '', + ); + + expect(checkbox).toBeDisabled(); + expect(btnTest).toBeDisabled(); + expect(btnSave).toBeEnabled(); + + const saveSpy = vi.spyOn(ssoStore, 'saveSamlConfig'); + const getSpy = vi.spyOn(ssoStore, 'getSamlConfig').mockResolvedValue(samlConfig); + await userEvent.click(btnSave); + + expect(saveSpy).toHaveBeenCalled(); + expect(getSpy).toHaveBeenCalled(); + expect(checkbox).toBeEnabled(); + expect(btnTest).toBeEnabled(); + expect(btnSave).toBeEnabled(); + }); +}); From ea8c48ad37f785b713b663fb488be7306f66d089 Mon Sep 17 00:00:00 2001 From: OlegIvaniv Date: Thu, 13 Apr 2023 18:16:24 +0200 Subject: [PATCH 05/30] ci: Skip e2e tests for community PRs (no-changelog) (#5971) ci: Skip e2e tests for community PRs --- .github/workflows/e2e-tests-pr.yml | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/.github/workflows/e2e-tests-pr.yml b/.github/workflows/e2e-tests-pr.yml index 7071ea565e52f..e73f02aa804a8 100644 --- a/.github/workflows/e2e-tests-pr.yml +++ b/.github/workflows/e2e-tests-pr.yml @@ -7,24 +7,10 @@ on: - 'master' jobs: - # We disable this for now because cancelling runs makes the Cypress Cloud tests to hang. - # cancel-previous-runs: - # runs-on: ubuntu-latest - # name: 'Cancel previous e2e test runs' - # strategy: - # matrix: - # node-version: [16.x] - - # steps: - # - name: 'Cancel previous runs' - # uses: styfle/cancel-workflow-action@0.9.0 - # with: - # access_token: ${{ github.token }} - run-e2e-tests: name: E2E [Electron/Node 16] uses: ./.github/workflows/e2e-reusable.yml - if: ${{ github.event.review.state == 'approved' }} + if: ${{ github.event.review.state == 'approved' && !contains(github.event.pull_request.labels.*.name, 'community') }} with: branch: ${{ github.event.pull_request.head.ref }} user: ${{ github.event.pull_request.user.login || 'PR User' }} @@ -40,7 +26,7 @@ jobs: if: always() steps: - name: E2E success comment - if: needs.run-e2e-tests.result == 'success' + if: ${{!contains(github.event.pull_request.labels.*.name, 'community') || needs.run-e2e-tests.result == 'success' }} uses: peter-evans/create-or-update-comment@v3 with: issue-number: ${{ github.event.pull_request.number }} @@ -57,6 +43,10 @@ jobs: :warning: Some Cypress E2E specs are failing, please fix them before merging token: ${{ secrets.GITHUB_TOKEN }} + - name: Success job if community PR + if: ${{ contains(github.event.pull_request.labels.*.name, 'community') }} + run: exit 0 + - name: Fail job if run-e2e-tests failed if: needs.run-e2e-tests.result == 'failure' run: exit 1 From 536d8109b02d1a0f771055c36ff0f45dae08281e Mon Sep 17 00:00:00 2001 From: OlegIvaniv Date: Thu, 13 Apr 2023 18:46:44 +0200 Subject: [PATCH 06/30] fix(editor): Only treat as CTRL pressed by default on touch devices for MouseEvent (#5968) --- packages/editor-ui/src/composables/useDeviceSupport.ts | 2 +- packages/editor-ui/src/mixins/deviceSupportHelpers.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/editor-ui/src/composables/useDeviceSupport.ts b/packages/editor-ui/src/composables/useDeviceSupport.ts index 7c0be6fe666a2..1c7e5083eee3e 100644 --- a/packages/editor-ui/src/composables/useDeviceSupport.ts +++ b/packages/editor-ui/src/composables/useDeviceSupport.ts @@ -19,7 +19,7 @@ export default function useDeviceSupportHelpers(): DeviceSupportHelpers { const controlKeyCode = ref(isMacOs.value ? 'Meta' : 'Control'); function isCtrlKeyPressed(e: MouseEvent | KeyboardEvent): boolean { - if (isTouchDevice.value === true) { + if (isTouchDevice.value === true && e instanceof MouseEvent) { return true; } if (isMacOs.value) { diff --git a/packages/editor-ui/src/mixins/deviceSupportHelpers.ts b/packages/editor-ui/src/mixins/deviceSupportHelpers.ts index a8506f6edf545..6b316bf87d8f1 100644 --- a/packages/editor-ui/src/mixins/deviceSupportHelpers.ts +++ b/packages/editor-ui/src/mixins/deviceSupportHelpers.ts @@ -19,7 +19,7 @@ export const deviceSupportHelpers = Vue.extend({ }, methods: { isCtrlKeyPressed(e: MouseEvent | KeyboardEvent): boolean { - if (this.isTouchDevice === true) { + if (this.isTouchDevice === true && e instanceof MouseEvent) { return true; } if (this.isMacOs) { From 49d838f628a124f3497165437a384e78d8a8ff63 Mon Sep 17 00:00:00 2001 From: Omar Ajoue Date: Fri, 14 Apr 2023 11:05:42 +0200 Subject: [PATCH 07/30] fix(core): Fix broken API permissions in public API (#5978) --- .../handlers/executions/executions.handler.ts | 4 +- .../handlers/workflows/workflows.service.ts | 5 +- .../integration/publicApi/executions.test.ts | 139 +++++++++++++++++- 3 files changed, 142 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts b/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts index f3cbab6a3d09f..0ffb8202e2859 100644 --- a/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts @@ -91,7 +91,7 @@ export = { // user does not have workflows hence no executions // or the execution he is trying to access belongs to a workflow he does not own - if (!sharedWorkflowsIds.length) { + if (!sharedWorkflowsIds.length || (workflowId && !sharedWorkflowsIds.includes(workflowId))) { return res.status(200).json({ data: [], nextCursor: null }); } @@ -105,7 +105,7 @@ export = { limit, lastId, includeData, - ...(workflowId && { workflowIds: [workflowId] }), + workflowIds: workflowId ? [workflowId] : sharedWorkflowsIds, excludedExecutionsIds: runningExecutionsIds, }; diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts index d727fd88055ee..adb37dce57dd1 100644 --- a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts @@ -18,9 +18,12 @@ function insertIf(condition: boolean, elements: string[]): string[] { } export async function getSharedWorkflowIds(user: User): Promise { + const where = user.globalRole.name === 'owner' ? {} : { userId: user.id }; const sharedWorkflows = await Db.collections.SharedWorkflow.find({ - where: { userId: user.id }, + where, + select: ['workflowId'], }); + return sharedWorkflows.map(({ workflowId }) => workflowId); return sharedWorkflows.map(({ workflowId }) => workflowId); } diff --git a/packages/cli/test/integration/publicApi/executions.test.ts b/packages/cli/test/integration/publicApi/executions.test.ts index 4c70977dcef75..1d5debdbfb1e3 100644 --- a/packages/cli/test/integration/publicApi/executions.test.ts +++ b/packages/cli/test/integration/publicApi/executions.test.ts @@ -10,7 +10,11 @@ import * as testDb from '../shared/testDb'; let app: Application; let owner: User; +let user1: User; +let user2: User; let authOwnerAgent: SuperAgentTest; +let authUser1Agent: SuperAgentTest; +let authUser2Agent: SuperAgentTest; let workflowRunner: ActiveWorkflowRunner; beforeAll(async () => { @@ -21,7 +25,10 @@ beforeAll(async () => { }); const globalOwnerRole = await testDb.getGlobalOwnerRole(); + const globalUserRole = await testDb.getGlobalMemberRole(); owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); + user1 = await testDb.createUser({ globalRole: globalUserRole, apiKey: randomApiKey() }); + user2 = await testDb.createUser({ globalRole: globalUserRole, apiKey: randomApiKey() }); await utils.initBinaryManager(); await utils.initNodeTypes(); @@ -46,6 +53,20 @@ beforeEach(async () => { version: 1, }); + authUser1Agent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: user1, + version: 1, + }); + + authUser2Agent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: user2, + version: 1, + }); + config.set('userManagement.disabled', false); config.set('userManagement.isInstanceOwnerSetUp', true); }); @@ -70,7 +91,7 @@ describe('GET /executions/:id', () => { test('should fail due to invalid API Key', testWithAPIKey('get', '/executions/1', 'abcXYZ')); - test('should get an execution', async () => { + test('owner should be able to get an execution owned by him', async () => { const workflow = await testDb.createWorkflow({}, owner); const execution = await testDb.createSuccessfulExecution(workflow); @@ -101,6 +122,46 @@ describe('GET /executions/:id', () => { expect(workflowId).toBe(execution.workflowId); expect(waitTill).toBeNull(); }); + + test('owner should be able to read executions of other users', async () => { + const workflow = await testDb.createWorkflow({}, user1); + const execution = await testDb.createSuccessfulExecution(workflow); + + const response = await authOwnerAgent.get(`/executions/${execution.id}`); + + expect(response.statusCode).toBe(200); + }); + + test('member should be able to fetch his own executions', async () => { + const workflow = await testDb.createWorkflow({}, user1); + const execution = await testDb.createSuccessfulExecution(workflow); + + const response = await authUser1Agent.get(`/executions/${execution.id}`); + + expect(response.statusCode).toBe(200); + }); + + test('member should not get an execution of another user without the workflow being shared', async () => { + const workflow = await testDb.createWorkflow({}, owner); + + const execution = await testDb.createSuccessfulExecution(workflow); + + const response = await authUser1Agent.get(`/executions/${execution.id}`); + + expect(response.statusCode).toBe(404); + }); + + test('member should be able to fetch executions of workflows shared with him', async () => { + const workflow = await testDb.createWorkflow({}, user1); + + const execution = await testDb.createSuccessfulExecution(workflow); + + await testDb.shareWorkflowWithUsers(workflow, [user2]); + + const response = await authUser2Agent.get(`/executions/${execution.id}`); + + expect(response.statusCode).toBe(200); + }); }); describe('DELETE /executions/:id', () => { @@ -324,10 +385,8 @@ describe('GET /executions', () => { const savedExecutions = await testDb.createManyExecutions( 2, workflow, - // @ts-ignore testDb.createSuccessfulExecution, ); - // @ts-ignore await testDb.createManyExecutions(2, workflow2, testDb.createSuccessfulExecution); const response = await authOwnerAgent.get(`/executions`).query({ @@ -362,4 +421,78 @@ describe('GET /executions', () => { expect(waitTill).toBeNull(); } }); + + test('owner should retrieve all executions regardless of ownership', async () => { + const [firstWorkflowForUser1, secondWorkflowForUser1] = await testDb.createManyWorkflows( + 2, + {}, + user1, + ); + await testDb.createManyExecutions(2, firstWorkflowForUser1, testDb.createSuccessfulExecution); + await testDb.createManyExecutions(2, secondWorkflowForUser1, testDb.createSuccessfulExecution); + + const [firstWorkflowForUser2, secondWorkflowForUser2] = await testDb.createManyWorkflows( + 2, + {}, + user2, + ); + await testDb.createManyExecutions(2, firstWorkflowForUser2, testDb.createSuccessfulExecution); + await testDb.createManyExecutions(2, secondWorkflowForUser2, testDb.createSuccessfulExecution); + + const response = await authOwnerAgent.get(`/executions`); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(8); + expect(response.body.nextCursor).toBe(null); + }); + + test('member should not see executions of workflows not shared with him', async () => { + const [firstWorkflowForUser1, secondWorkflowForUser1] = await testDb.createManyWorkflows( + 2, + {}, + user1, + ); + await testDb.createManyExecutions(2, firstWorkflowForUser1, testDb.createSuccessfulExecution); + await testDb.createManyExecutions(2, secondWorkflowForUser1, testDb.createSuccessfulExecution); + + const [firstWorkflowForUser2, secondWorkflowForUser2] = await testDb.createManyWorkflows( + 2, + {}, + user2, + ); + await testDb.createManyExecutions(2, firstWorkflowForUser2, testDb.createSuccessfulExecution); + await testDb.createManyExecutions(2, secondWorkflowForUser2, testDb.createSuccessfulExecution); + + const response = await authUser1Agent.get(`/executions`); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(4); + expect(response.body.nextCursor).toBe(null); + }); + + test('member should also see executions of workflows shared with him', async () => { + const [firstWorkflowForUser1, secondWorkflowForUser1] = await testDb.createManyWorkflows( + 2, + {}, + user1, + ); + await testDb.createManyExecutions(2, firstWorkflowForUser1, testDb.createSuccessfulExecution); + await testDb.createManyExecutions(2, secondWorkflowForUser1, testDb.createSuccessfulExecution); + + const [firstWorkflowForUser2, secondWorkflowForUser2] = await testDb.createManyWorkflows( + 2, + {}, + user2, + ); + await testDb.createManyExecutions(2, firstWorkflowForUser2, testDb.createSuccessfulExecution); + await testDb.createManyExecutions(2, secondWorkflowForUser2, testDb.createSuccessfulExecution); + + await testDb.shareWorkflowWithUsers(firstWorkflowForUser2, [user1]); + + const response = await authUser1Agent.get(`/executions`); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(6); + expect(response.body.nextCursor).toBe(null); + }); }); From 0b48088296a7f826be3664f10c847b9dca753732 Mon Sep 17 00:00:00 2001 From: Michael Kret <88898367+michael-radency@users.noreply.github.com> Date: Fri, 14 Apr 2023 13:39:52 +0300 Subject: [PATCH 08/30] feat(core): Support for google service account in HTTP node --- .../credentials/GoogleApi.credentials.ts | 113 +++++++++++++++++- .../HttpRequest/V3/HttpRequestV3.node.ts | 12 ++ 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/packages/nodes-base/credentials/GoogleApi.credentials.ts b/packages/nodes-base/credentials/GoogleApi.credentials.ts index fcd0484895593..2ecf1ec67c73e 100644 --- a/packages/nodes-base/credentials/GoogleApi.credentials.ts +++ b/packages/nodes-base/credentials/GoogleApi.credentials.ts @@ -1,9 +1,22 @@ -import type { ICredentialType, INodeProperties } from 'n8n-workflow'; +import type { + ICredentialDataDecryptedObject, + ICredentialType, + IHttpRequestOptions, + INodeProperties, +} from 'n8n-workflow'; + +import moment from 'moment-timezone'; + +import jwt from 'jsonwebtoken'; + +import type { AxiosRequestConfig } from 'axios'; + +import axios from 'axios'; export class GoogleApi implements ICredentialType { name = 'googleApi'; - displayName = 'Google API'; + displayName = 'Google Service Account API'; documentationUrl = 'google/service-account'; @@ -52,5 +65,101 @@ export class GoogleApi implements ICredentialType { description: 'The email address of the user for which the application is requesting delegated access', }, + { + displayName: 'Set up for use in HTTP Request node', + name: 'httpNode', + type: 'boolean', + default: false, + }, + { + displayName: + "When using the HTTP Request node, you must specify the scopes you want to send. In other nodes, they're added automatically", + name: 'httpWarning', + type: 'notice', + default: '', + displayOptions: { + show: { + httpNode: [true], + }, + }, + }, + { + displayName: 'Scope(s)', + name: 'scopes', + type: 'string', + default: '', + description: + 'You can find the scopes for services here', + displayOptions: { + show: { + httpNode: [true], + }, + }, + }, ]; + + async authenticate( + credentials: ICredentialDataDecryptedObject, + requestOptions: IHttpRequestOptions, + ): Promise { + if (!credentials.httpNode) return requestOptions; + + const privateKey = (credentials.privateKey as string).replace(/\\n/g, '\n').trim(); + const credentialsScopes = (credentials.scopes as string).replace(/\\n/g, '\n').trim(); + credentials.email = (credentials.email as string).trim(); + + const regex = /[,\s\n]+/; + const scopes = credentialsScopes + .split(regex) + .filter((scope) => scope) + .join(' '); + + const now = moment().unix(); + + const signature = jwt.sign( + { + iss: credentials.email, + sub: credentials.delegatedEmail || credentials.email, + scope: scopes, + aud: 'https://oauth2.googleapis.com/token', + iat: now, + exp: now + 3600, + }, + privateKey, + { + algorithm: 'RS256', + header: { + kid: privateKey, + typ: 'JWT', + alg: 'RS256', + }, + }, + ); + + const axiosRequestConfig: AxiosRequestConfig = { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + method: 'POST', + data: new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion: signature, + }).toString(), + url: 'https://oauth2.googleapis.com/token', + }; + + const result = await axios(axiosRequestConfig); + + const { access_token } = result.data; + + const requestOptionsWithAuth: IHttpRequestOptions = { + ...requestOptions, + headers: { + ...requestOptions.headers, + Authorization: `Bearer ${access_token}`, + }, + }; + + return requestOptionsWithAuth; + } } diff --git a/packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts b/packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts index 4e51690e2b417..0618bd957f9fc 100644 --- a/packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts +++ b/packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts @@ -137,6 +137,18 @@ export class HttpRequestV3 implements INodeType { }, }, }, + { + displayName: + 'Make sure you have specified the scope(s) for the Service Account in the credential', + name: 'googleApiWarning', + type: 'notice', + default: '', + displayOptions: { + show: { + nodeCredentialType: ['googleApi'], + }, + }, + }, { displayName: 'Generic Auth Type', name: 'genericAuthType', From dc0055fc5c904f3b54b1c8685c0fa811fb70f50e Mon Sep 17 00:00:00 2001 From: Csaba Tuncsik Date: Fri, 14 Apr 2023 13:17:03 +0200 Subject: [PATCH 09/30] test(editor): Move test utils to the proper place (#5975) --- packages/editor-ui/src/__tests__/utils.ts | 105 ++++++++++++++++++ .../__tests__/ExecutionsList.test.ts | 2 +- .../src/components/__tests__/SSOLogin.test.ts | 2 +- .../src/stores/__tests__/sso.test.ts | 2 +- .../src/utils/__tests__/userUtils.test.ts | 2 +- packages/editor-ui/src/utils/testUtils.ts | 103 ----------------- .../views/__tests__/SamlOnboarding.test.ts | 2 +- .../src/views/__tests__/SettingsSso.test.ts | 2 +- 8 files changed, 111 insertions(+), 109 deletions(-) delete mode 100644 packages/editor-ui/src/utils/testUtils.ts diff --git a/packages/editor-ui/src/__tests__/utils.ts b/packages/editor-ui/src/__tests__/utils.ts index 49834e3854e13..8e0cacf179bc8 100644 --- a/packages/editor-ui/src/__tests__/utils.ts +++ b/packages/editor-ui/src/__tests__/utils.ts @@ -1,3 +1,5 @@ +import { ISettingsState, UserManagementAuthenticationMethod } from '@/Interface'; + export const retry = (assertion: () => any, { interval = 20, timeout = 200 } = {}) => { return new Promise((resolve, reject) => { const startTime = Date.now(); @@ -15,3 +17,106 @@ export const retry = (assertion: () => any, { interval = 20, timeout = 200 } = { tryAgain(); }); }; + +export const waitAllPromises = () => new Promise((resolve) => setTimeout(resolve)); + +export const SETTINGS_STORE_DEFAULT_STATE: ISettingsState = { + settings: { + userActivationSurveyEnabled: false, + allowedModules: {}, + communityNodesEnabled: false, + defaultLocale: '', + endpointWebhook: '', + endpointWebhookTest: '', + enterprise: { + advancedExecutionFilters: false, + sharing: false, + ldap: false, + saml: false, + logStreaming: false, + }, + executionMode: '', + executionTimeout: 0, + hideUsagePage: false, + hiringBannerEnabled: false, + instanceId: '', + isNpmAvailable: false, + license: { environment: 'production' }, + logLevel: 'info', + maxExecutionTimeout: 0, + oauthCallbackUrls: { oauth1: '', oauth2: '' }, + onboardingCallPromptEnabled: false, + personalizationSurveyEnabled: false, + posthog: { + apiHost: '', + apiKey: '', + autocapture: false, + debug: false, + disableSessionRecording: false, + enabled: false, + }, + publicApi: { enabled: false, latestVersion: 0, path: '', swaggerUi: { enabled: false } }, + pushBackend: 'sse', + saveDataErrorExecution: '', + saveDataSuccessExecution: '', + saveManualExecutions: false, + sso: { + ldap: { loginEnabled: false, loginLabel: '' }, + saml: { loginEnabled: false, loginLabel: '' }, + }, + telemetry: { enabled: false }, + templates: { enabled: false, host: '' }, + timezone: '', + urlBaseEditor: '', + urlBaseWebhook: '', + userManagement: { + enabled: false, + smtpSetup: false, + authenticationMethod: UserManagementAuthenticationMethod.Email, + }, + versionCli: '', + versionNotifications: { + enabled: false, + endpoint: '', + infoUrl: '', + }, + workflowCallerPolicyDefaultOption: 'any', + workflowTagsDisabled: false, + deployment: { + type: 'default', + }, + }, + promptsData: { + message: '', + title: '', + showContactPrompt: false, + showValueSurvey: false, + }, + userManagement: { + enabled: false, + showSetupOnFirstLoad: false, + smtpSetup: false, + authenticationMethod: UserManagementAuthenticationMethod.Email, + }, + templatesEndpointHealthy: false, + api: { + enabled: false, + latestVersion: 0, + path: '/', + swaggerUi: { + enabled: false, + }, + }, + ldap: { + loginLabel: '', + loginEnabled: false, + }, + saml: { + loginLabel: '', + loginEnabled: false, + }, + onboardingCallPromptEnabled: false, + saveDataErrorExecution: 'all', + saveDataSuccessExecution: 'all', + saveManualExecutions: false, +}; diff --git a/packages/editor-ui/src/components/__tests__/ExecutionsList.test.ts b/packages/editor-ui/src/components/__tests__/ExecutionsList.test.ts index 09eaa92ad2368..10faa739409bc 100644 --- a/packages/editor-ui/src/components/__tests__/ExecutionsList.test.ts +++ b/packages/editor-ui/src/components/__tests__/ExecutionsList.test.ts @@ -14,7 +14,7 @@ import { showMessage } from '@/mixins/showMessage'; import { i18nInstance } from '@/plugins/i18n'; import type { IWorkflowShortResponse } from '@/Interface'; import type { IExecutionsSummary } from 'n8n-workflow'; -import { waitAllPromises } from '@/utils/testUtils'; +import { waitAllPromises } from '@/__tests__/utils'; const workflowDataFactory = (): IWorkflowShortResponse => ({ createdAt: faker.date.past().toDateString(), diff --git a/packages/editor-ui/src/components/__tests__/SSOLogin.test.ts b/packages/editor-ui/src/components/__tests__/SSOLogin.test.ts index 0ee32a747c17b..fd09c860e6126 100644 --- a/packages/editor-ui/src/components/__tests__/SSOLogin.test.ts +++ b/packages/editor-ui/src/components/__tests__/SSOLogin.test.ts @@ -5,7 +5,7 @@ import { merge } from 'lodash-es'; import SSOLogin from '@/components/SSOLogin.vue'; import { STORES } from '@/constants'; import { useSSOStore } from '@/stores/sso'; -import { SETTINGS_STORE_DEFAULT_STATE } from '@/utils/testUtils'; +import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils'; import { afterEach } from 'vitest'; let pinia: ReturnType; diff --git a/packages/editor-ui/src/stores/__tests__/sso.test.ts b/packages/editor-ui/src/stores/__tests__/sso.test.ts index 3d79f07037f60..eea1d3684e6e8 100644 --- a/packages/editor-ui/src/stores/__tests__/sso.test.ts +++ b/packages/editor-ui/src/stores/__tests__/sso.test.ts @@ -3,7 +3,7 @@ import { useSettingsStore } from '@/stores/settings'; import { useSSOStore } from '@/stores/sso'; import { merge } from 'lodash-es'; import { IN8nUISettings } from '@/Interface'; -import { SETTINGS_STORE_DEFAULT_STATE } from '@/utils/testUtils'; +import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils'; let ssoStore: ReturnType; let settingsStore: ReturnType; diff --git a/packages/editor-ui/src/utils/__tests__/userUtils.test.ts b/packages/editor-ui/src/utils/__tests__/userUtils.test.ts index d9864b09b4438..0a9f98e64d9f3 100644 --- a/packages/editor-ui/src/utils/__tests__/userUtils.test.ts +++ b/packages/editor-ui/src/utils/__tests__/userUtils.test.ts @@ -7,7 +7,7 @@ import { useSSOStore } from '@/stores/sso'; import { IN8nUISettings, IUser } from '@/Interface'; import { routes } from '@/router'; import { VIEWS } from '@/constants'; -import { SETTINGS_STORE_DEFAULT_STATE } from '@/utils/testUtils'; +import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils'; const DEFAULT_SETTINGS: IN8nUISettings = SETTINGS_STORE_DEFAULT_STATE.settings; diff --git a/packages/editor-ui/src/utils/testUtils.ts b/packages/editor-ui/src/utils/testUtils.ts deleted file mode 100644 index 48f11a2c15f79..0000000000000 --- a/packages/editor-ui/src/utils/testUtils.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { ISettingsState, UserManagementAuthenticationMethod } from '@/Interface'; - -export const waitAllPromises = () => new Promise((resolve) => setTimeout(resolve)); - -export const SETTINGS_STORE_DEFAULT_STATE: ISettingsState = { - settings: { - allowedModules: {}, - communityNodesEnabled: false, - defaultLocale: '', - endpointWebhook: '', - endpointWebhookTest: '', - enterprise: { - advancedExecutionFilters: false, - sharing: false, - ldap: false, - saml: false, - logStreaming: false, - }, - executionMode: '', - executionTimeout: 0, - hideUsagePage: false, - hiringBannerEnabled: false, - instanceId: '', - isNpmAvailable: false, - license: { environment: 'production' }, - logLevel: 'info', - maxExecutionTimeout: 0, - oauthCallbackUrls: { oauth1: '', oauth2: '' }, - onboardingCallPromptEnabled: false, - personalizationSurveyEnabled: false, - posthog: { - apiHost: '', - apiKey: '', - autocapture: false, - debug: false, - disableSessionRecording: false, - enabled: false, - }, - publicApi: { enabled: false, latestVersion: 0, path: '', swaggerUi: { enabled: false } }, - pushBackend: 'sse', - saveDataErrorExecution: '', - saveDataSuccessExecution: '', - saveManualExecutions: false, - sso: { - ldap: { loginEnabled: false, loginLabel: '' }, - saml: { loginEnabled: false, loginLabel: '' }, - }, - telemetry: { enabled: false }, - templates: { enabled: false, host: '' }, - timezone: '', - urlBaseEditor: '', - urlBaseWebhook: '', - userManagement: { - enabled: false, - smtpSetup: false, - authenticationMethod: UserManagementAuthenticationMethod.Email, - }, - versionCli: '', - versionNotifications: { - enabled: false, - endpoint: '', - infoUrl: '', - }, - workflowCallerPolicyDefaultOption: 'any', - workflowTagsDisabled: false, - deployment: { - type: 'default', - }, - }, - promptsData: { - message: '', - title: '', - showContactPrompt: false, - showValueSurvey: false, - }, - userManagement: { - enabled: false, - showSetupOnFirstLoad: false, - smtpSetup: false, - authenticationMethod: UserManagementAuthenticationMethod.Email, - }, - templatesEndpointHealthy: false, - api: { - enabled: false, - latestVersion: 0, - path: '/', - swaggerUi: { - enabled: false, - }, - }, - ldap: { - loginLabel: '', - loginEnabled: false, - }, - saml: { - loginLabel: '', - loginEnabled: false, - }, - onboardingCallPromptEnabled: false, - saveDataErrorExecution: 'all', - saveDataSuccessExecution: 'all', - saveManualExecutions: false, -}; diff --git a/packages/editor-ui/src/views/__tests__/SamlOnboarding.test.ts b/packages/editor-ui/src/views/__tests__/SamlOnboarding.test.ts index 524144b160aff..3cb5b30153244 100644 --- a/packages/editor-ui/src/views/__tests__/SamlOnboarding.test.ts +++ b/packages/editor-ui/src/views/__tests__/SamlOnboarding.test.ts @@ -7,7 +7,7 @@ import { merge } from 'lodash-es'; import SamlOnboarding from '@/views/SamlOnboarding.vue'; import { useSSOStore } from '@/stores/sso'; import { STORES } from '@/constants'; -import { SETTINGS_STORE_DEFAULT_STATE, waitAllPromises } from '@/utils/testUtils'; +import { SETTINGS_STORE_DEFAULT_STATE, waitAllPromises } from '@/__tests__/utils'; import { i18nInstance } from '@/plugins/i18n'; vi.mock('vue-router/composables', () => { diff --git a/packages/editor-ui/src/views/__tests__/SettingsSso.test.ts b/packages/editor-ui/src/views/__tests__/SettingsSso.test.ts index 1da485a4c2bd7..a5675017186aa 100644 --- a/packages/editor-ui/src/views/__tests__/SettingsSso.test.ts +++ b/packages/editor-ui/src/views/__tests__/SettingsSso.test.ts @@ -7,7 +7,7 @@ import { faker } from '@faker-js/faker'; import SettingsSso from '@/views/SettingsSso.vue'; import { useSSOStore } from '@/stores/sso'; import { STORES } from '@/constants'; -import { SETTINGS_STORE_DEFAULT_STATE, waitAllPromises } from '@/utils/testUtils'; +import { SETTINGS_STORE_DEFAULT_STATE, waitAllPromises } from '@/__tests__/utils'; import { i18nInstance } from '@/plugins/i18n'; import { SamlPreferences, SamlPreferencesExtractedData } from '@/Interface'; From ee7d5a841eeb46be980ced24b1dd8ef5850ade0a Mon Sep 17 00:00:00 2001 From: Csaba Tuncsik Date: Fri, 14 Apr 2023 13:17:20 +0200 Subject: [PATCH 10/30] test(editor): Set 'data-test-id' in unit tests, so they match with Cypress (#5976) --- packages/editor-ui/src/__tests__/setup.ts | 3 +++ .../src/components/ExecutionFilter.vue | 24 +++++++++---------- .../src/components/ExecutionsList.vue | 20 ++++++++-------- .../src/components/__tests__/SSOLogin.test.ts | 2 +- packages/editor-ui/src/views/SettingsSso.vue | 8 +++---- .../src/views/__tests__/AuthView.test.ts | 2 +- 6 files changed, 31 insertions(+), 28 deletions(-) diff --git a/packages/editor-ui/src/__tests__/setup.ts b/packages/editor-ui/src/__tests__/setup.ts index 350dbc992a6cb..28fef7c5d7849 100644 --- a/packages/editor-ui/src/__tests__/setup.ts +++ b/packages/editor-ui/src/__tests__/setup.ts @@ -1,8 +1,11 @@ import '@testing-library/jest-dom'; +import { configure } from '@testing-library/vue'; import Vue from 'vue'; import '../plugins'; import { I18nPlugin } from '@/plugins/i18n'; +configure({ testIdAttribute: 'data-test-id' }); + Vue.config.productionTip = false; Vue.config.devtools = false; diff --git a/packages/editor-ui/src/components/ExecutionFilter.vue b/packages/editor-ui/src/components/ExecutionFilter.vue index 2b0e0dd87bfb8..371a5e43f5c66 100644 --- a/packages/editor-ui/src/components/ExecutionFilter.vue +++ b/packages/editor-ui/src/components/ExecutionFilter.vue @@ -141,19 +141,19 @@ onBeforeMount(() => { type="tertiary" size="medium" :active="!!countSelectedFilterProps" - data-testid="executions-filter-button" + data-test-id="executions-filter-button" > {{ countSelectedFilterProps }} {{ $locale.baseText('executionsList.filters') }} -
+
@@ -263,7 +263,7 @@ onBeforeMount(() => { {{ $locale.baseText('executionsFilter.customData.inputTooltip.link') }} @@ -278,7 +278,7 @@ onBeforeMount(() => { :placeholder="$locale.baseText('executionsFilter.savedDataKeyPlaceholder')" :value="filter.metadata[0]?.key" @input="onFilterMetaChange(0, 'key', $event)" - data-testid="execution-filter-saved-data-key-input" + data-test-id="execution-filter-saved-data-key-input" />
@@ -314,7 +314,7 @@ onBeforeMount(() => { @click="onFilterReset" size="large" text - data-testid="executions-filter-reset-button" + data-test-id="executions-filter-reset-button" > {{ $locale.baseText('executionsFilter.reset') }} diff --git a/packages/editor-ui/src/components/ExecutionsList.vue b/packages/editor-ui/src/components/ExecutionsList.vue index 992a0f16b9f35..7fb081d318961 100644 --- a/packages/editor-ui/src/components/ExecutionsList.vue +++ b/packages/editor-ui/src/components/ExecutionsList.vue @@ -8,7 +8,7 @@ class="mr-xl" v-model="autoRefresh" @change="handleAutoRefreshToggle" - data-testid="execution-auto-refresh-checkbox" + data-test-id="execution-auto-refresh-checkbox" > {{ $locale.baseText('executionsList.autoRefresh') }} @@ -27,7 +27,7 @@ " :value="allExistingSelected" @change="handleCheckAllExistingChange" - data-testid="select-all-executions-checkbox" + data-test-id="select-all-executions-checkbox" /> @@ -39,7 +39,7 @@ @change="handleCheckAllVisibleChange" :disabled="finishedExecutionsCount < 1" label="" - data-testid="select-visible-executions-checkbox" + data-test-id="select-visible-executions-checkbox" /> @@ -64,7 +64,7 @@ :value="selectedItems[execution.id] || allExistingSelected" @change="handleCheckboxChanged(execution.id)" label="" - data-testid="select-execution-checkbox" + data-test-id="select-execution-checkbox" /> + + + + +" +`; + +exports[`VariablesRow > should show key and value inputs in edit mode 1`] = ` +" + + + + +" +`; diff --git a/packages/editor-ui/src/components/layouts/PageViewLayoutList.vue b/packages/editor-ui/src/components/layouts/PageViewLayoutList.vue index f66ee0a2ad889..84246d22dc2ef 100644 --- a/packages/editor-ui/src/components/layouts/PageViewLayoutList.vue +++ b/packages/editor-ui/src/components/layouts/PageViewLayoutList.vue @@ -1,5 +1,18 @@ + + + + @@ -177,6 +196,7 @@ import ResourceFiltersDropdown from '@/components/forms/ResourceFiltersDropdown. import { mapStores } from 'pinia'; import { useSettingsStore } from '@/stores/settings'; import { useUsersStore } from '@/stores/users'; +import { DatatableColumn } from 'n8n-design-system'; export interface IResource { id: string; @@ -213,13 +233,17 @@ export default mixins(showMessage, debounceHelper).extend({ type: String, default: '' as IResourceKeyType, }, + displayName: { + type: Function as PropType<(resource: IResource) => string>, + default: (resource: IResource) => resource.name, + }, resources: { type: Array, default: (): IResource[] => [], }, - itemSize: { - type: Number, - default: 80, + disabled: { + type: Boolean, + default: false, }, initialize: { type: Function as PropType<() => Promise>, @@ -240,13 +264,37 @@ export default mixins(showMessage, debounceHelper).extend({ type: Boolean, default: true, }, + showFiltersDropdown: { + type: Boolean, + default: true, + }, + sortFns: { + type: Object as PropType number>>, + default: (): Record number> => ({}), + }, + sortOptions: { + type: Array as PropType, + default: () => ['lastUpdated', 'lastCreated', 'nameAsc', 'nameDesc'], + }, + type: { + type: String as PropType<'datatable' | 'list'>, + default: 'list', + }, + typeProps: { + type: Object as PropType<{ itemSize: number } | { columns: DatatableColumn[] }>, + default: () => ({ + itemSize: 0, + }), + }, }, data() { return { loading: true, isOwnerSubview: false, - sortBy: 'lastUpdated', + sortBy: this.sortOptions[0], hasFilters: false, + currentPage: 1, + rowsPerPage: 10 as number | '*', resettingFilters: false, EnterpriseEditionFeature, }; @@ -292,7 +340,7 @@ export default mixins(showMessage, debounceHelper).extend({ if (this.filters.search) { const searchString = this.filters.search.toLowerCase(); - matches = matches && resource.name.toLowerCase().includes(searchString); + matches = matches && this.displayName(resource).toLowerCase().includes(searchString); } if (this.additionalFiltersHandler) { @@ -305,15 +353,23 @@ export default mixins(showMessage, debounceHelper).extend({ return filtered.sort((a, b) => { switch (this.sortBy) { case 'lastUpdated': - return new Date(b.updatedAt).valueOf() - new Date(a.updatedAt).valueOf(); + return this.sortFns['lastUpdated'] + ? this.sortFns['lastUpdated'](a, b) + : new Date(b.updatedAt).valueOf() - new Date(a.updatedAt).valueOf(); case 'lastCreated': - return new Date(b.createdAt).valueOf() - new Date(a.createdAt).valueOf(); + return this.sortFns['lastCreated'] + ? this.sortFns['lastCreated'](a, b) + : new Date(b.createdAt).valueOf() - new Date(a.createdAt).valueOf(); case 'nameAsc': - return a.name.trim().localeCompare(b.name.trim()); + return this.sortFns['nameAsc'] + ? this.sortFns['nameAsc'](a, b) + : this.displayName(a).trim().localeCompare(this.displayName(b).trim()); case 'nameDesc': - return b.name.localeCompare(a.name); + return this.sortFns['nameDesc'] + ? this.sortFns['nameDesc'](a, b) + : this.displayName(b).trim().localeCompare(this.displayName(a).trim()); default: - return 0; + return this.sortFns[this.sortBy] ? this.sortFns[this.sortBy](a, b) : 0; } }); }, @@ -333,6 +389,12 @@ export default mixins(showMessage, debounceHelper).extend({ this.loading = false; this.$nextTick(this.focusSearchInput); }, + setCurrentPage(page: number) { + this.currentPage = page; + }, + setRowsPerPage(rowsPerPage: number | '*') { + this.rowsPerPage = rowsPerPage; + }, resetFilters() { Object.keys(this.filters).forEach((key) => { this.filters[key] = Array.isArray(this.filters[key]) ? [] : ''; @@ -418,7 +480,8 @@ export default mixins(showMessage, debounceHelper).extend({ 'filters.search'() { this.callDebounced('sendFiltersTelemetry', { debounceTime: 1000, trailing: true }, 'search'); }, - sortBy() { + sortBy(newValue) { + this.$emit('sort', newValue); this.sendSortingTelemetry(); }, }, @@ -446,6 +509,10 @@ export default mixins(showMessage, debounceHelper).extend({ //flex-direction: column; } +.listWrapper { + height: 100%; +} + .sort-and-filter { display: flex; flex-direction: row; @@ -460,4 +527,8 @@ export default mixins(showMessage, debounceHelper).extend({ .card-loading { height: 69px; } + +.datatable { + padding-bottom: var(--spacing-s); +} diff --git a/packages/editor-ui/src/composables/index.ts b/packages/editor-ui/src/composables/index.ts new file mode 100644 index 0000000000000..86ee35fe50793 --- /dev/null +++ b/packages/editor-ui/src/composables/index.ts @@ -0,0 +1,8 @@ +export * from './useCopyToClipboard'; +export * from './useExternalHooks'; +export * from './useGlobalLinkActions'; +export * from './useI18n'; +export * from './useMessage'; +export * from './useTelemetry'; +export * from './useToast'; +export * from './useUpgradeLink'; diff --git a/packages/editor-ui/src/composables/useCopyToClipboard.ts b/packages/editor-ui/src/composables/useCopyToClipboard.ts new file mode 100644 index 0000000000000..7e71370d3b79a --- /dev/null +++ b/packages/editor-ui/src/composables/useCopyToClipboard.ts @@ -0,0 +1,5 @@ +import copyToClipboard from 'copy-to-clipboard'; + +export function useCopyToClipboard(): (text: string) => void { + return copyToClipboard; +} diff --git a/packages/editor-ui/src/composables/useExternalHooks.ts b/packages/editor-ui/src/composables/useExternalHooks.ts new file mode 100644 index 0000000000000..ff3e3ab48123c --- /dev/null +++ b/packages/editor-ui/src/composables/useExternalHooks.ts @@ -0,0 +1,12 @@ +import { IExternalHooks } from '@/Interface'; +import { IDataObject } from 'n8n-workflow'; +import { useWebhooksStore } from '@/stores'; +import { runExternalHook } from '@/mixins/externalHooks'; + +export function useExternalHooks(): IExternalHooks { + return { + async run(eventName: string, metadata?: IDataObject): Promise { + return await runExternalHook.call(this, eventName, useWebhooksStore(), metadata); + }, + }; +} diff --git a/packages/editor-ui/src/composables/useI18n.ts b/packages/editor-ui/src/composables/useI18n.ts new file mode 100644 index 0000000000000..61d7bf807a50d --- /dev/null +++ b/packages/editor-ui/src/composables/useI18n.ts @@ -0,0 +1,5 @@ +import { i18n } from '@/plugins/i18n'; + +export function useI18n() { + return i18n; +} diff --git a/packages/editor-ui/src/composables/useMessage.ts b/packages/editor-ui/src/composables/useMessage.ts new file mode 100644 index 0000000000000..2a5dd6e8e7ac8 --- /dev/null +++ b/packages/editor-ui/src/composables/useMessage.ts @@ -0,0 +1,65 @@ +import type { ElMessageBoxOptions } from 'element-ui/types/message-box'; +import { Message, MessageBox } from 'element-ui'; + +export function useMessage() { + async function alert( + message: string, + configOrTitle: string | ElMessageBoxOptions | undefined, + config: ElMessageBoxOptions | undefined, + ) { + const resolvedConfig = { + ...(config || (typeof configOrTitle === 'object' ? configOrTitle : {})), + cancelButtonClass: 'btn--cancel', + confirmButtonClass: 'btn--confirm', + }; + + if (typeof configOrTitle === 'string') { + return await MessageBox.alert(message, configOrTitle, resolvedConfig); + } + return await MessageBox.alert(message, resolvedConfig); + } + + async function confirm( + message: string, + configOrTitle: string | ElMessageBoxOptions | undefined, + config: ElMessageBoxOptions | undefined, + ) { + const resolvedConfig = { + ...(config || (typeof configOrTitle === 'object' ? configOrTitle : {})), + cancelButtonClass: 'btn--cancel', + confirmButtonClass: 'btn--confirm', + distinguishCancelAndClose: true, + showClose: config?.showClose || false, + closeOnClickModal: false, + }; + + if (typeof configOrTitle === 'string') { + return await MessageBox.confirm(message, configOrTitle, resolvedConfig); + } + return await MessageBox.confirm(message, resolvedConfig); + } + + async function prompt( + message: string, + configOrTitle: string | ElMessageBoxOptions | undefined, + config: ElMessageBoxOptions | undefined, + ) { + const resolvedConfig = { + ...(config || (typeof configOrTitle === 'object' ? configOrTitle : {})), + cancelButtonClass: 'btn--cancel', + confirmButtonClass: 'btn--confirm', + }; + + if (typeof configOrTitle === 'string') { + return await MessageBox.prompt(message, configOrTitle, resolvedConfig); + } + return await MessageBox.prompt(message, resolvedConfig); + } + + return { + alert, + confirm, + prompt, + message: Message, + }; +} diff --git a/packages/editor-ui/src/composables/useTelemetry.ts b/packages/editor-ui/src/composables/useTelemetry.ts new file mode 100644 index 0000000000000..856b15477b84e --- /dev/null +++ b/packages/editor-ui/src/composables/useTelemetry.ts @@ -0,0 +1,5 @@ +import { Telemetry, telemetry } from '@/plugins/telemetry'; + +export function useTelemetry(): Telemetry { + return telemetry; +} diff --git a/packages/editor-ui/src/composables/useToast.ts b/packages/editor-ui/src/composables/useToast.ts new file mode 100644 index 0000000000000..97fff04d7030a --- /dev/null +++ b/packages/editor-ui/src/composables/useToast.ts @@ -0,0 +1,142 @@ +import { Notification } from 'element-ui'; +import type { ElNotificationComponent, ElNotificationOptions } from 'element-ui/types/notification'; +import type { MessageType } from 'element-ui/types/message'; +import { sanitizeHtml } from '@/utils'; +import { useTelemetry } from '@/composables/useTelemetry'; +import { useWorkflowsStore } from '@/stores'; +import { useI18n } from './useI18n'; +import { useExternalHooks } from './useExternalHooks'; + +const messageDefaults: Partial> = { + dangerouslyUseHTMLString: true, + position: 'bottom-right', +}; + +const stickyNotificationQueue: ElNotificationComponent[] = []; + +export function useToast() { + const telemetry = useTelemetry(); + const workflowsStore = useWorkflowsStore(); + const externalHooks = useExternalHooks(); + const i18n = useI18n(); + + function showMessage( + messageData: Omit & { message?: string }, + track = true, + ) { + messageData = { ...messageDefaults, ...messageData }; + messageData.message = messageData.message + ? sanitizeHtml(messageData.message) + : messageData.message; + + const notification = Notification(messageData as ElNotificationOptions); + + if (messageData.duration === 0) { + stickyNotificationQueue.push(notification); + } + + if (messageData.type === 'error' && track) { + telemetry.track('Instance FE emitted error', { + error_title: messageData.title, + error_message: messageData.message, + workflow_id: workflowsStore.workflowId, + }); + } + + return notification; + } + + function showToast(config: { + title: string; + message: string; + onClick?: () => void; + onClose?: () => void; + duration?: number; + customClass?: string; + closeOnClick?: boolean; + type?: MessageType; + }) { + // eslint-disable-next-line prefer-const + let notification: ElNotificationComponent; + if (config.closeOnClick) { + const cb = config.onClick; + config.onClick = () => { + if (notification) { + notification.close(); + } + + if (cb) { + cb(); + } + }; + } + + notification = showMessage({ + title: config.title, + message: config.message, + onClick: config.onClick, + onClose: config.onClose, + duration: config.duration, + customClass: config.customClass, + type: config.type, + }); + + return notification; + } + + function collapsableDetails({ description, node }: Error) { + if (!description) return ''; + + const errorDescription = + description.length > 500 ? `${description.slice(0, 500)}...` : description; + + return ` +
+
+
+ + ${i18n.baseText('showMessage.showDetails')} + +

${node.name}: ${errorDescription}

+
+ `; + } + + function showError(e: Error | unknown, title: string, message?: string) { + const error = e as Error; + const messageLine = message ? `${message}
` : ''; + showMessage( + { + title, + message: ` + ${messageLine} + ${error.message} + ${collapsableDetails(error)}`, + type: 'error', + duration: 0, + }, + false, + ); + + externalHooks.run('showMessage.showError', { + title, + message, + errorMessage: error.message, + }); + + telemetry.track('Instance FE emitted error', { + error_title: title, + error_description: message, + error_message: error.message, + workflow_id: workflowsStore.workflowId, + }); + } + + return { + showMessage, + showToast, + showError, + }; +} diff --git a/packages/editor-ui/src/composables/useUpgradeLink.ts b/packages/editor-ui/src/composables/useUpgradeLink.ts new file mode 100644 index 0000000000000..89cbaeeb4aca5 --- /dev/null +++ b/packages/editor-ui/src/composables/useUpgradeLink.ts @@ -0,0 +1,25 @@ +import { BaseTextKey } from '@/plugins/i18n'; +import { useUIStore, useUsageStore } from '@/stores'; +import { useI18n } from '@/composables'; +import { computed } from 'vue'; + +export function useUpgradeLink(queryParams = { default: '', desktop: '' }) { + const uiStore = useUIStore(); + const usageStore = useUsageStore(); + const i18n = useI18n(); + + const upgradeLinkUrl = computed(() => { + const linkUrlTranslationKey = uiStore.contextBasedTranslationKeys.upgradeLinkUrl as BaseTextKey; + let url = i18n.baseText(linkUrlTranslationKey); + + if (linkUrlTranslationKey.endsWith('.upgradeLinkUrl')) { + url = `${usageStore.viewPlansUrl}${queryParams.default}`; + } else if (linkUrlTranslationKey.endsWith('.desktop')) { + url = `${url}${queryParams.desktop}`; + } + + return url; + }); + + return { upgradeLinkUrl }; +} diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index 1dd90ccbada3b..b74bc5bbf87b2 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -371,6 +371,7 @@ export enum VIEWS { TEMPLATE = 'TemplatesWorkflowView', TEMPLATES = 'TemplatesSearchView', CREDENTIALS = 'CredentialsView', + VARIABLES = 'VariablesView', NEW_WORKFLOW = 'NodeViewNew', WORKFLOW = 'NodeViewExisting', DEMO = 'WorkflowDemo', @@ -434,6 +435,7 @@ export const MAPPING_PARAMS = [ '$resumeWebhookUrl', '$runIndex', '$today', + '$vars', '$workflow', ]; @@ -457,6 +459,7 @@ export enum EnterpriseEditionFeature { Sharing = 'sharing', Ldap = 'ldap', LogStreaming = 'logStreaming', + Variables = 'variables', Saml = 'saml', } export const MAIN_NODE_PANEL_WIDTH = 360; diff --git a/packages/editor-ui/src/mixins/workflowHelpers.ts b/packages/editor-ui/src/mixins/workflowHelpers.ts index c4037e6d62b77..cd79304c9d2a4 100644 --- a/packages/editor-ui/src/mixins/workflowHelpers.ts +++ b/packages/editor-ui/src/mixins/workflowHelpers.ts @@ -65,6 +65,7 @@ import { useWorkflowsEEStore } from '@/stores/workflows.ee'; import { useUsersStore } from '@/stores/users'; import { getWorkflowPermissions, IPermissions } from '@/permissions'; import { ICredentialsResponse } from '@/Interface'; +import { useEnvironmentsStore } from '@/stores'; let cachedWorkflowKey: string | null = ''; let cachedWorkflow: Workflow | null = null; @@ -150,6 +151,7 @@ export function resolveParameter( mode: 'test', resumeUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME, }, + $vars: useEnvironmentsStore().variablesAsObject, // deprecated $executionId: PLACEHOLDER_FILLED_AT_EXECUTION_TIME, diff --git a/packages/editor-ui/src/permissions.ts b/packages/editor-ui/src/permissions.ts index 82ba1e0bb83b8..a2300d6399b7f 100644 --- a/packages/editor-ui/src/permissions.ts +++ b/packages/editor-ui/src/permissions.ts @@ -4,7 +4,13 @@ * @usage getCredentialPermissions(user, credential).isOwner; */ -import { IUser, ICredentialsResponse, IRootState, IWorkflowDb } from '@/Interface'; +import { + IUser, + ICredentialsResponse, + IRootState, + IWorkflowDb, + EnvironmentVariable, +} from '@/Interface'; import { EnterpriseEditionFeature, PLACEHOLDER_EMPTY_WORKFLOW_ID } from '@/constants'; import { useSettingsStore } from './stores/settings'; @@ -130,3 +136,23 @@ export const getWorkflowPermissions = (user: IUser | null, workflow: IWorkflowDb return parsePermissionsTable(user, table); }; + +export const getVariablesPermissions = (user: IUser | null) => { + const table: IPermissionsTable = [ + { + name: 'create', + test: [UserRole.InstanceOwner], + }, + { + name: 'edit', + test: [UserRole.InstanceOwner], + }, + { + name: 'delete', + test: [UserRole.InstanceOwner], + }, + { name: 'use', test: () => true }, + ]; + + return parsePermissionsTable(user, table); +}; diff --git a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts index 1362354167ff8..060a9604fc584 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts @@ -20,6 +20,7 @@ import { NativeDoc } from 'n8n-workflow/src/Extensions/Extensions'; import { isFunctionOption } from './typeGuards'; import { luxonInstanceDocs } from './nativesAutocompleteDocs/luxon.instance.docs'; import { luxonStaticDocs } from './nativesAutocompleteDocs/luxon.static.docs'; +import { useEnvironmentsStore } from '@/stores'; /** * Resolution-based completions offered according to datatype. @@ -31,7 +32,8 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul if (word.from === word.to && !context.explicit) return null; - const [base, tail] = splitBaseTail(word.text); + // eslint-disable-next-line prefer-const + let [base, tail] = splitBaseTail(word.text); let options: Completion[] = []; @@ -39,6 +41,8 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul options = luxonStaticOptions().map(stripExcessParens(context)); } else if (base === 'Object') { options = objectGlobalOptions().map(stripExcessParens(context)); + } else if (base === '$vars') { + options = variablesOptions(); } else { let resolved: Resolved; @@ -331,6 +335,22 @@ function ensureKeyCanBeResolved(obj: IDataObject, key: string) { } } +export const variablesOptions = () => { + const environmentsStore = useEnvironmentsStore(); + const variables = environmentsStore.variables; + + return variables.map((variable) => + createCompletionOption('Object', variable.key, 'keyword', { + doc: { + name: variable.key, + returnType: 'string', + description: i18n.baseText('codeNodeEditor.completer.$vars.varName'), + docURL: 'https://docs.n8n.io/environments/variables/', + }, + }), + ); +}; + /** * Methods and fields defined on a Luxon `DateTime` class instance. */ diff --git a/packages/editor-ui/src/plugins/components.ts b/packages/editor-ui/src/plugins/components.ts index 567a26d1ccfb1..075742d8451d4 100644 --- a/packages/editor-ui/src/plugins/components.ts +++ b/packages/editor-ui/src/plugins/components.ts @@ -7,10 +7,10 @@ import VueAgile from 'vue-agile'; import 'regenerator-runtime/runtime'; import ElementUI from 'element-ui'; -import { Loading, MessageBox, Message, Notification } from 'element-ui'; +import { Loading, MessageBox, Notification } from 'element-ui'; import { designSystemComponents } from 'n8n-design-system'; -import { ElMessageBoxOptions } from 'element-ui/types/message-box'; import EnterpriseEdition from '@/components/EnterpriseEdition.ee.vue'; +import { useMessage } from '@/composables/useMessage'; Vue.use(Fragment.Plugin); Vue.use(VueAgile); @@ -25,62 +25,11 @@ Vue.use(Loading.directive); Vue.prototype.$loading = Loading.service; Vue.prototype.$msgbox = MessageBox; -Vue.prototype.$alert = async ( - message: string, - configOrTitle: string | ElMessageBoxOptions | undefined, - config: ElMessageBoxOptions | undefined, -) => { - let temp = config || (typeof configOrTitle === 'object' ? configOrTitle : {}); - temp = { - ...temp, - cancelButtonClass: 'btn--cancel', - confirmButtonClass: 'btn--confirm', - }; +const messageService = useMessage(); - if (typeof configOrTitle === 'string') { - return await MessageBox.alert(message, configOrTitle, temp); - } - return await MessageBox.alert(message, temp); -}; - -Vue.prototype.$confirm = async ( - message: string, - configOrTitle: string | ElMessageBoxOptions | undefined, - config: ElMessageBoxOptions | undefined, -) => { - let temp = config || (typeof configOrTitle === 'object' ? configOrTitle : {}); - temp = { - ...temp, - cancelButtonClass: 'btn--cancel', - confirmButtonClass: 'btn--confirm', - distinguishCancelAndClose: true, - showClose: config.showClose || false, - closeOnClickModal: false, - }; - - if (typeof configOrTitle === 'string') { - return await MessageBox.confirm(message, configOrTitle, temp); - } - return await MessageBox.confirm(message, temp); -}; - -Vue.prototype.$prompt = async ( - message: string, - configOrTitle: string | ElMessageBoxOptions | undefined, - config: ElMessageBoxOptions | undefined, -) => { - let temp = config || (typeof configOrTitle === 'object' ? configOrTitle : {}); - temp = { - ...temp, - cancelButtonClass: 'btn--cancel', - confirmButtonClass: 'btn--confirm', - }; - - if (typeof configOrTitle === 'string') { - return await MessageBox.prompt(message, configOrTitle, temp); - } - return await MessageBox.prompt(message, temp); -}; +Vue.prototype.$alert = messageService.alert; +Vue.prototype.$confirm = messageService.confirm; +Vue.prototype.$prompt = messageService.prompt; +Vue.prototype.$message = messageService.message; Vue.prototype.$notify = Notification; -Vue.prototype.$message = Message; diff --git a/packages/editor-ui/src/plugins/i18n/index.ts b/packages/editor-ui/src/plugins/i18n/index.ts index aa1f434342b72..bb3daaf5f70fa 100644 --- a/packages/editor-ui/src/plugins/i18n/index.ts +++ b/packages/editor-ui/src/plugins/i18n/index.ts @@ -341,6 +341,7 @@ export class I18nClass { $min: this.baseText('codeNodeEditor.completer.$min'), $runIndex: this.baseText('codeNodeEditor.completer.$runIndex'), $today: this.baseText('codeNodeEditor.completer.$today'), + $vars: this.baseText('codeNodeEditor.completer.$vars'), $workflow: this.baseText('codeNodeEditor.completer.$workflow'), }; diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 62bce2e86a450..781d56acc504c 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -142,6 +142,8 @@ "codeNodeEditor.completer.$prevNode.runIndex": "The run of the node providing input data to the current one", "codeNodeEditor.completer.$runIndex": "The index of the current run of this node", "codeNodeEditor.completer.$today": "A timestamp representing the current day (at midnight, as a Luxon object)", + "codeNodeEditor.completer.$vars": "The variables defined in your instance", + "codeNodeEditor.completer.$vars.varName": "Variable set on this n8n instance. All variables evaluate to strings.", "codeNodeEditor.completer.$workflow": "Information about the workflow", "codeNodeEditor.completer.$workflow.active": "Whether the workflow is active or not (boolean)", "codeNodeEditor.completer.$workflow.id": "The ID of the workflow", @@ -595,6 +597,7 @@ "mainSidebar.confirmMessage.workflowDelete.headline": "Delete Workflow?", "mainSidebar.confirmMessage.workflowDelete.message": "Are you sure that you want to delete '{workflowName}'?", "mainSidebar.credentials": "Credentials", + "mainSidebar.variables": "Variables", "mainSidebar.help": "Help", "mainSidebar.helpMenuItems.course": "Course", "mainSidebar.helpMenuItems.documentation": "Documentation", @@ -1601,6 +1604,39 @@ "importParameter.showError.invalidProtocol1.title": "Use the {node} node", "importParameter.showError.invalidProtocol2.title": "Invalid Protocol", "importParameter.showError.invalidProtocol.message": "The HTTP node doesn’t support {protocol} requests", + "variables.heading": "Variables", + "variables.add": "Add Variable", + "variables.add.unavailable": "Upgrade plan to keep using variables", + "variables.add.onlyOwnerCanCreate": "Only owner can create variables", + "variables.empty.heading": "{name}, let's set up a variable", + "variables.empty.heading.userNotSetup": "Set up a variable", + "variables.empty.description": "Variables can be used to store data that can be referenced easily across multiple workflows.", + "variables.empty.button": "Add first variable", + "variables.noResults": "No variables found", + "variables.sort.nameAsc": "Sort by name (A-Z)", + "variables.sort.nameDesc": "Sort by name (Z-A)", + "variables.table.key": "Key", + "variables.table.value": "Value", + "variables.table.usage": "Usage Syntax", + "variables.editing.key.placeholder": "Enter a name", + "variables.editing.value.placeholder": "Enter a value", + "variables.editing.key.error.startsWithLetter": "This field may only start with a letter", + "variables.editing.key.error.jsonKey": "This field may contain only letters, numbers, and underscores", + "variables.row.button.save": "Save", + "variables.row.button.cancel": "Cancel", + "variables.row.button.edit": "Edit", + "variables.row.button.edit.onlyOwnerCanSave": "Only owner can edit variables", + "variables.row.button.delete": "Delete", + "variables.row.button.delete.onlyOwnerCanDelete": "Only owner can delete variables", + "variables.row.usage.copiedToClipboard": "Copied to clipboard", + "variables.row.usage.copyToClipboard": "Copy to clipboard", + "variables.search.placeholder": "Search variables...", + "variables.errors.save": "Error while saving variable", + "variables.errors.delete": "Error while deleting variable", + "variables.modals.deleteConfirm.title": "Delete variable", + "variables.modals.deleteConfirm.message": "Are you sure you want to delete the variable \"{name}\"? This cannot be undone.", + "variables.modals.deleteConfirm.confirmButton": "Delete", + "variables.modals.deleteConfirm.cancelButton": "Cancel", "contextual.credentials.sharing.unavailable.title": "Upgrade to collaborate", "contextual.credentials.sharing.unavailable.title.cloud": "Upgrade to collaborate", "contextual.credentials.sharing.unavailable.title.desktop": "Upgrade to n8n Cloud to collaborate", @@ -1625,6 +1661,14 @@ "contextual.workflows.sharing.unavailable.button.cloud": "Upgrade now", "contextual.workflows.sharing.unavailable.button.desktop": "View plans", + "contextual.variables.unavailable.title": "Available on Enterprise plan", + "contextual.variables.unavailable.title.cloud": "Available on Power plan", + "contextual.variables.unavailable.title.desktop": "Upgrade to n8n Cloud to collaborate", + "contextual.variables.unavailable.description": "Variables can be used to store and access data across workflows. Reference them in n8n using the prefix $vars (e.g. $vars.myVariable). Variables are immutable and cannot be modified within your workflows.
Learn more in the docs.", + "contextual.variables.unavailable.button": "View plans", + "contextual.variables.unavailable.button.cloud": "Upgrade now", + "contextual.variables.unavailable.button.desktop": "View plans", + "contextual.users.settings.unavailable.title": "Upgrade to add users", "contextual.users.settings.unavailable.title.cloud": "Upgrade to add users", "contextual.users.settings.unavailable.title.desktop": "Upgrade to add users", diff --git a/packages/editor-ui/src/plugins/icons/custom.ts b/packages/editor-ui/src/plugins/icons/custom.ts new file mode 100644 index 0000000000000..9817abf254e84 --- /dev/null +++ b/packages/editor-ui/src/plugins/icons/custom.ts @@ -0,0 +1,13 @@ +import type { IconDefinition, IconName, IconPrefix } from '@fortawesome/fontawesome-svg-core'; + +export const faVariable: IconDefinition = { + prefix: 'fas' as IconPrefix, + iconName: 'variable' as IconName, + icon: [ + 52, + 52, + [], + 'e001', + 'M42.6,17.8c2.4,0,7.2-2,7.2-8.4c0-6.4-4.6-6.8-6.1-6.8c-2.8,0-5.6,2-8.1,6.3c-2.5,4.4-5.3,9.1-5.3,9.1 l-0.1,0c-0.6-3.1-1.1-5.6-1.3-6.7c-0.5-2.7-3.6-8.4-9.9-8.4c-6.4,0-12.2,3.7-12.2,3.7l0,0C5.8,7.3,5.1,8.5,5.1,9.9 c0,2.1,1.7,3.9,3.9,3.9c0.6,0,1.2-0.2,1.7-0.4l0,0c0,0,4.8-2.7,5.9,0c0.3,0.8,0.6,1.7,0.9,2.7c1.2,4.2,2.4,9.1,3.3,13.5l-4.2,6 c0,0-4.7-1.7-7.1-1.7s-7.2,2-7.2,8.4s4.6,6.8,6.1,6.8c2.8,0,5.6-2,8.1-6.3c2.5-4.4,5.3-9.1,5.3-9.1c0.8,4,1.5,7.1,1.9,8.5 c1.6,4.5,5.3,7.2,10.1,7.2c0,0,5,0,10.9-3.3c1.4-0.6,2.4-2,2.4-3.6c0-2.1-1.7-3.9-3.9-3.9c-0.6,0-1.2,0.2-1.7,0.4l0,0 c0,0-4.2,2.4-5.6,0.5c-1-2-1.9-4.6-2.6-7.8c-0.6-2.8-1.3-6.2-2-9.5l4.3-6.2C35.5,16.1,40.2,17.8,42.6,17.8z', + ], +}; diff --git a/packages/editor-ui/src/plugins/icons.ts b/packages/editor-ui/src/plugins/icons/index.ts similarity index 93% rename from packages/editor-ui/src/plugins/icons.ts rename to packages/editor-ui/src/plugins/icons/index.ts index 7656b7aef4e1f..90257a28ce316 100644 --- a/packages/editor-ui/src/plugins/icons.ts +++ b/packages/editor-ui/src/plugins/icons/index.ts @@ -1,6 +1,7 @@ import Vue from 'vue'; -import { IconDefinition, library } from '@fortawesome/fontawesome-svg-core'; +import { library } from '@fortawesome/fontawesome-svg-core'; +import type { IconDefinition } from '@fortawesome/fontawesome-svg-core'; import { faAngleDoubleLeft, faAngleDown, @@ -128,12 +129,12 @@ import { faStickyNote as faSolidStickyNote, faUserLock, } from '@fortawesome/free-solid-svg-icons'; +import { faVariable } from './custom'; import { faStickyNote } from '@fortawesome/free-regular-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function addIcon(icon: any) { - library.add(icon as IconDefinition); +function addIcon(icon: IconDefinition) { + library.add(icon); } addIcon(faAngleDoubleLeft); @@ -239,7 +240,7 @@ addIcon(faSignOutAlt); addIcon(faSlidersH); addIcon(faSpinner); addIcon(faSolidStickyNote); -addIcon(faStickyNote); +addIcon(faStickyNote as IconDefinition); addIcon(faStop); addIcon(faSun); addIcon(faSync); @@ -259,6 +260,7 @@ addIcon(faUser); addIcon(faUserCircle); addIcon(faUserFriends); addIcon(faUsers); +addIcon(faVariable); addIcon(faVideo); addIcon(faTree); addIcon(faUserLock); diff --git a/packages/editor-ui/src/router.ts b/packages/editor-ui/src/router.ts index 7a548d3768381..1dd3e2317060c 100644 --- a/packages/editor-ui/src/router.ts +++ b/packages/editor-ui/src/router.ts @@ -28,6 +28,7 @@ import TemplatesSearchView from '@/views/TemplatesSearchView.vue'; import CredentialsView from '@/views/CredentialsView.vue'; import ExecutionsView from '@/views/ExecutionsView.vue'; import WorkflowsView from '@/views/WorkflowsView.vue'; +import VariablesView from '@/views/VariablesView.vue'; import { IPermissions } from './Interface'; import { LOGIN_STATUS, ROLE } from '@/utils'; import { RouteConfigSingleView } from 'vue-router/types/router'; @@ -178,6 +179,21 @@ export const routes = [ }, }, }, + { + path: '/variables', + name: VIEWS.VARIABLES, + components: { + default: VariablesView, + sidebar: MainSidebar, + }, + meta: { + permissions: { + allow: { + loginStatus: [LOGIN_STATUS.LoggedIn], + }, + }, + }, + }, { path: '/executions', name: VIEWS.EXECUTIONS, diff --git a/packages/editor-ui/src/stores/__tests__/environments.spec.ts b/packages/editor-ui/src/stores/__tests__/environments.spec.ts new file mode 100644 index 0000000000000..3df3f6b28d165 --- /dev/null +++ b/packages/editor-ui/src/stores/__tests__/environments.spec.ts @@ -0,0 +1,99 @@ +import { afterAll, beforeAll } from 'vitest'; +import { setActivePinia, createPinia } from 'pinia'; +import { setupServer } from '@/__tests__/server'; +import { useEnvironmentsStore } from '@/stores/environments.ee'; +import { EnvironmentVariable } from '@/Interface'; + +describe('store', () => { + let server: ReturnType; + const seedRecordsCount = 3; + + beforeAll(() => { + server = setupServer(); + server.createList('variable', seedRecordsCount); + }); + + beforeEach(() => { + setActivePinia(createPinia()); + }); + + afterAll(() => { + server.shutdown(); + }); + + describe('variables', () => { + describe('fetchAllVariables()', () => { + it('should fetch all credentials', async () => { + const environmentsStore = useEnvironmentsStore(); + await environmentsStore.fetchAllVariables(); + + expect(environmentsStore.variables).toHaveLength(seedRecordsCount); + }); + }); + + describe('createVariable()', () => { + it('should store a new variable', async () => { + const variable: Omit = { + key: 'ENV_VAR', + value: 'SECRET', + }; + const environmentsStore = useEnvironmentsStore(); + + await environmentsStore.fetchAllVariables(); + const recordsCount = environmentsStore.variables.length; + + expect(environmentsStore.variables).toHaveLength(recordsCount); + + await environmentsStore.createVariable(variable); + + expect(environmentsStore.variables).toHaveLength(recordsCount + 1); + expect(environmentsStore.variables[0]).toMatchObject(variable); + }); + }); + + describe('updateVariable()', () => { + it('should update an existing variable', async () => { + const updateValue: Partial = { + key: 'ENV_VAR', + value: 'SECRET', + }; + + const environmentsStore = useEnvironmentsStore(); + await environmentsStore.fetchAllVariables(); + + await environmentsStore.updateVariable({ + ...environmentsStore.variables[0], + ...updateValue, + }); + + expect(environmentsStore.variables[0]).toMatchObject(updateValue); + }); + }); + + describe('deleteVariable()', () => { + it('should delete an existing variable', async () => { + const environmentsStore = useEnvironmentsStore(); + await environmentsStore.fetchAllVariables(); + const recordsCount = environmentsStore.variables.length; + + await environmentsStore.deleteVariable(environmentsStore.variables[0]); + + expect(environmentsStore.variables).toHaveLength(recordsCount - 1); + }); + }); + + describe('variablesAsObject', () => { + it('should return variables as a key-value object', async () => { + const environmentsStore = useEnvironmentsStore(); + await environmentsStore.fetchAllVariables(); + + expect(environmentsStore.variablesAsObject).toEqual( + environmentsStore.variables.reduce>((acc, variable) => { + acc[variable.key] = variable.value; + return acc; + }, {}), + ); + }); + }); + }); +}); diff --git a/packages/editor-ui/src/stores/usage.test.ts b/packages/editor-ui/src/stores/__tests__/usage.test.ts similarity index 100% rename from packages/editor-ui/src/stores/usage.test.ts rename to packages/editor-ui/src/stores/__tests__/usage.test.ts diff --git a/packages/editor-ui/src/stores/environments.ee.ts b/packages/editor-ui/src/stores/environments.ee.ts new file mode 100644 index 0000000000000..ac28effe9a737 --- /dev/null +++ b/packages/editor-ui/src/stores/environments.ee.ts @@ -0,0 +1,65 @@ +import { defineStore } from 'pinia'; +import { useSettingsStore } from '@/stores/settings'; +import { computed, ref } from 'vue'; +import { EnvironmentVariable } from '@/Interface'; +import * as environmentsApi from '@/api/environments.ee'; +import { useRootStore } from '@/stores/n8nRootStore'; +import { createVariable } from '@/api/environments.ee'; + +export const useEnvironmentsStore = defineStore('environments', () => { + const rootStore = useRootStore(); + + const variables = ref([]); + + async function fetchAllVariables() { + const data = await environmentsApi.getVariables(rootStore.getRestApiContext); + + variables.value = data; + + return data; + } + + async function createVariable(variable: Omit) { + const data = await environmentsApi.createVariable(rootStore.getRestApiContext, variable); + + variables.value.unshift(data); + + return data; + } + + async function updateVariable(variable: EnvironmentVariable) { + const data = await environmentsApi.updateVariable(rootStore.getRestApiContext, variable); + + variables.value = variables.value.map((v) => (v.id === data.id ? data : v)); + + return data; + } + + async function deleteVariable(variable: EnvironmentVariable) { + const data = await environmentsApi.deleteVariable(rootStore.getRestApiContext, { + id: variable.id, + }); + + variables.value = variables.value.filter((v) => v.id !== variable.id); + + return data; + } + + const variablesAsObject = computed(() => + variables.value.reduce>((acc, variable) => { + acc[variable.key] = variable.value; + return acc; + }, {}), + ); + + return { + variables, + variablesAsObject, + fetchAllVariables, + createVariable, + updateVariable, + deleteVariable, + }; +}); + +export default useEnvironmentsStore; diff --git a/packages/editor-ui/src/stores/index.ts b/packages/editor-ui/src/stores/index.ts new file mode 100644 index 0000000000000..927ff1ae2961f --- /dev/null +++ b/packages/editor-ui/src/stores/index.ts @@ -0,0 +1,23 @@ +export * from './canvas'; +export * from './communityNodes'; +export * from './credentials'; +export * from './environments.ee'; +export * from './history'; +export * from './logStreamingStore'; +export * from './n8nRootStore'; +export * from './ndv'; +export * from './nodeCreator'; +export * from './nodeTypes'; +export * from './posthog'; +export * from './segment'; +export * from './settings'; +export * from './tags'; +export * from './telemetry'; +export * from './templates'; +export * from './ui'; +export * from './usage'; +export * from './users'; +export * from './versions'; +export * from './webhooks'; +export * from './workflows.ee'; +export * from './workflows'; diff --git a/packages/editor-ui/src/stores/ui.ts b/packages/editor-ui/src/stores/ui.ts index 666dc7af5bad2..f87a3e3744c95 100644 --- a/packages/editor-ui/src/stores/ui.ts +++ b/packages/editor-ui/src/stores/ui.ts @@ -215,6 +215,14 @@ export const useUIStore = defineStore(STORES.UI, { }, }, }, + variables: { + unavailable: { + title: `contextual.variables.unavailable.title${contextKey}`, + description: 'contextual.variables.unavailable.description', + action: `contextual.variables.unavailable.action${contextKey}`, + button: `contextual.variables.unavailable.button${contextKey}`, + }, + }, users: { settings: { unavailable: { diff --git a/packages/editor-ui/src/styles/autocomplete-theme.scss b/packages/editor-ui/src/styles/autocomplete-theme.scss index 8b65338ac4f9d..f67c42975936e 100644 --- a/packages/editor-ui/src/styles/autocomplete-theme.scss +++ b/packages/editor-ui/src/styles/autocomplete-theme.scss @@ -16,6 +16,12 @@ .cm-tooltip-autocomplete { background-color: var(--color-background-xlight) !important; + .cm-tooltip { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + li .cm-completionLabel { color: var(--color-success); } diff --git a/packages/editor-ui/src/views/CredentialsView.vue b/packages/editor-ui/src/views/CredentialsView.vue index 901ca18ebe872..7c037bce62da4 100644 --- a/packages/editor-ui/src/views/CredentialsView.vue +++ b/packages/editor-ui/src/views/CredentialsView.vue @@ -6,7 +6,7 @@ :initialize="initialize" :filters="filters" :additional-filters-handler="onFilter" - :item-size="77" + :type-props="{ itemSize: 77 }" @click:add="addCredential" @update:filters="filters = $event" > diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index 41d840499a828..b7a8f66eb8906 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -241,7 +241,7 @@ import { TelemetryHelpers, Workflow, } from 'n8n-workflow'; -import { +import type { ICredentialsResponse, IExecutionResponse, IWorkflowDb, @@ -277,7 +277,8 @@ import { useCredentialsStore } from '@/stores/credentials'; import { useTagsStore } from '@/stores/tags'; import { useNodeCreatorStore } from '@/stores/nodeCreator'; import { useCanvasStore } from '@/stores/canvas'; -import useWorkflowsEEStore from '@/stores/workflows.ee'; +import { useWorkflowsEEStore } from '@/stores/workflows.ee'; +import { useEnvironmentsStore } from '@/stores'; import * as NodeViewUtils from '@/utils/nodeViewUtils'; import { getAccountAge, getConnectionInfo, getNodeViewTab } from '@/utils'; import { useHistoryStore } from '@/stores/history'; @@ -470,6 +471,7 @@ export default mixins( useWorkflowsStore, useUsersStore, useNodeCreatorStore, + useEnvironmentsStore, useWorkflowsEEStore, useHistoryStore, ), @@ -3627,6 +3629,9 @@ export default mixins( async loadCredentials(): Promise { await this.credentialsStore.fetchAllCredentials(); }, + async loadVariables(): Promise { + await this.environmentsStore.fetchAllVariables(); + }, async loadNodesProperties(nodeInfos: INodeTypeNameVersion[]): Promise { const allNodes: INodeTypeDescription[] = this.nodeTypesStore.allNodeTypes; @@ -3882,6 +3887,7 @@ export default mixins( this.loadCredentials(), this.loadCredentialTypes(), ]; + this.loadVariables(); if (this.nodeTypesStore.allNodeTypes.length === 0) { loadPromises.push(this.loadNodeTypes()); diff --git a/packages/editor-ui/src/views/VariablesView.vue b/packages/editor-ui/src/views/VariablesView.vue new file mode 100644 index 0000000000000..b150a92f1bcaf --- /dev/null +++ b/packages/editor-ui/src/views/VariablesView.vue @@ -0,0 +1,338 @@ + + + + + + + diff --git a/packages/editor-ui/src/views/__tests__/VariablesView.spec.ts b/packages/editor-ui/src/views/__tests__/VariablesView.spec.ts new file mode 100644 index 0000000000000..d69c98d40eb72 --- /dev/null +++ b/packages/editor-ui/src/views/__tests__/VariablesView.spec.ts @@ -0,0 +1,49 @@ +import { afterAll, beforeAll } from 'vitest'; +import { setActivePinia, createPinia } from 'pinia'; +import { setupServer } from '@/__tests__/server'; +import { render } from '@testing-library/vue'; +import VariablesView from '@/views/VariablesView.vue'; +import { useSettingsStore, useUsersStore } from '@/stores'; + +describe('store', () => { + let server: ReturnType; + + beforeAll(() => { + server = setupServer(); + }); + + beforeEach(async () => { + setActivePinia(createPinia()); + + await useSettingsStore().getSettings(); + await useUsersStore().fetchUsers(); + await useUsersStore().loginWithCookie(); + }); + + afterAll(() => { + server.shutdown(); + }); + + it('should render loading state', () => { + const wrapper = render(VariablesView); + + expect(wrapper.container.querySelectorAll('.n8n-loading')).toHaveLength(3); + }); + + it('should render empty state', async () => { + const wrapper = render(VariablesView); + + await wrapper.findByTestId('empty-resources-list'); + expect(wrapper.getByTestId('empty-resources-list')).toBeVisible(); + }); + + it('should render variable entries', async () => { + server.createList('variable', 3); + + const wrapper = render(VariablesView); + + await wrapper.findByTestId('resources-table'); + expect(wrapper.getByTestId('resources-table')).toBeVisible(); + expect(wrapper.container.querySelectorAll('tr')).toHaveLength(4); + }); +}); diff --git a/packages/nodes-base/test/nodes/Helpers.ts b/packages/nodes-base/test/nodes/Helpers.ts index b1d0603ad6c50..baa5871a31984 100644 --- a/packages/nodes-base/test/nodes/Helpers.ts +++ b/packages/nodes-base/test/nodes/Helpers.ts @@ -133,6 +133,7 @@ export function WorkflowExecuteAdditionalData( webhookWaitingBaseUrl: 'webhook-waiting', webhookTestBaseUrl: 'webhook-test', userId: '123', + variables: {}, }; } diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 2aed293657961..f4f89d30edc7f 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -1693,6 +1693,7 @@ export interface IWorkflowExecuteAdditionalData { currentNodeParameters?: INodeParameters; executionTimeoutTimestamp?: number; userId: string; + variables: IDataObject; } export type WorkflowExecuteMode = diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 783bd8a887b00..650a9bfca1085 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -880,6 +880,9 @@ importers: codemirror-lang-n8n-expression: specifier: ^0.2.0 version: 0.2.0(@codemirror/state@6.1.4)(@codemirror/view@6.5.1)(@lezer/common@1.0.1) + copy-to-clipboard: + specifier: ^3.3.3 + version: 3.3.3 dateformat: specifier: ^3.0.3 version: 3.0.3 @@ -9831,6 +9834,12 @@ packages: is-plain-object: 5.0.0 dev: true + /copy-to-clipboard@3.3.3: + resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==} + dependencies: + toggle-selection: 1.0.6 + dev: false + /copy-to@2.0.1: resolution: {integrity: sha512-3DdaFaU/Zf1AnpLiFDeNCD4TOWe3Zl2RZaTzUvWiIk5ERzcCodOE20Vqq4fzCbNoHURFHT4/us/Lfq+S2zyY4w==} dev: false @@ -20304,6 +20313,10 @@ packages: through2: 2.0.5 dev: true + /toggle-selection@1.0.6: + resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==} + dev: false + /toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} From c171365d2a613ea1fb9b08c22c54be29d1c8ade7 Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Tue, 18 Apr 2023 07:28:12 -0400 Subject: [PATCH 28/30] fix(editor): Cleanup demo/video experiment (#5974) --- packages/editor-ui/src/constants.ts | 9 +----- packages/editor-ui/src/utils/nodeViewUtils.ts | 11 ------- packages/editor-ui/src/views/NodeView.vue | 30 +------------------ packages/editor-ui/src/views/SetupView.vue | 7 ++--- .../editor-ui/src/views/WorkflowsView.vue | 15 ++-------- 5 files changed, 6 insertions(+), 66 deletions(-) diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index b74bc5bbf87b2..f123b23d09b1c 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -536,19 +536,12 @@ export const KEEP_AUTH_IN_NDV_FOR_NODES = [HTTP_REQUEST_NODE_TYPE, WEBHOOK_NODE_ export const MAIN_AUTH_FIELD_NAME = 'authentication'; export const NODE_RESOURCE_FIELD_NAME = 'resource'; -export const ASSUMPTION_EXPERIMENT = { - name: 'adore-assumption-tests-1', - control: 'control', - demo: 'assumption-demo', - video: 'assumption-video', -}; - export const ONBOARDING_EXPERIMENT = { name: 'checklist_001', control: 'control', variant: 'variant', }; -export const EXPERIMENTS_TO_TRACK = [ASSUMPTION_EXPERIMENT.name, ONBOARDING_EXPERIMENT.name]; +export const EXPERIMENTS_TO_TRACK = [ONBOARDING_EXPERIMENT.name]; export const NODE_TYPES_EXCLUDED_FROM_OUTPUT_NAME_APPEND = [FILTER_NODE_TYPE]; diff --git a/packages/editor-ui/src/utils/nodeViewUtils.ts b/packages/editor-ui/src/utils/nodeViewUtils.ts index a626537ed1cec..6884924a38ac9 100644 --- a/packages/editor-ui/src/utils/nodeViewUtils.ts +++ b/packages/editor-ui/src/utils/nodeViewUtils.ts @@ -62,17 +62,6 @@ export const DEFAULT_PLACEHOLDER_TRIGGER_BUTTON = { }, }; -export const WELCOME_STICKY_NODE = { - name: QUICKSTART_NOTE_NAME, - type: STICKY_NODE_TYPE, - typeVersion: 1, - position: [0, 0] as XYPosition, - parameters: { - height: 320, - width: 380, - }, -}; - export const CONNECTOR_FLOWCHART_TYPE: ConnectorSpec = { type: N8nConnector.type, options: { diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index b7a8f66eb8906..470f06faf73eb 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -2533,35 +2533,7 @@ export default mixins( }, async tryToAddWelcomeSticky(): Promise { const newWorkflow = this.workflowData; - if (usePostHog().isVariantEnabled(ASSUMPTION_EXPERIMENT.name, ASSUMPTION_EXPERIMENT.video)) { - // For novice users (onboardingFlowEnabled == true) - // Inject welcome sticky note and zoom to fit - - if (newWorkflow?.onboardingFlowEnabled && !this.isReadOnly) { - // Position the welcome sticky left to the added trigger node - const position: XYPosition = [50, 250]; - - await this.addNodes([ - { - id: uuid(), - ...NodeViewUtils.WELCOME_STICKY_NODE, - parameters: { - // Use parameters from the template but add translated content - ...NodeViewUtils.WELCOME_STICKY_NODE.parameters, - content: this.$locale.baseText('onboardingWorkflow.stickyContent'), - }, - position, - }, - ]); - setTimeout(() => { - this.canvasStore.zoomToFit(); - this.canvasStore.canvasAddButtonPosition = [500, 350]; - this.$telemetry.track('welcome note inserted'); - }, 0); - } - } else { - this.canvasStore.zoomToFit(); - } + this.canvasStore.zoomToFit(); }, async initView(): Promise { if (this.$route.params.action === 'workflowSave') { diff --git a/packages/editor-ui/src/views/SetupView.vue b/packages/editor-ui/src/views/SetupView.vue index 14a362f1e8b9c..87b46c2b9401c 100644 --- a/packages/editor-ui/src/views/SetupView.vue +++ b/packages/editor-ui/src/views/SetupView.vue @@ -101,9 +101,6 @@ export default mixins(showMessage, restApi).extend({ }, computed: { ...mapStores(useCredentialsStore, useSettingsStore, useUIStore, useUsersStore), - isDemoTest(): boolean { - return usePostHog().isVariantEnabled(ASSUMPTION_EXPERIMENT.name, ASSUMPTION_EXPERIMENT.demo); - }, }, methods: { async confirmSetupOrGoBack(): Promise { @@ -163,7 +160,7 @@ export default mixins(showMessage, restApi).extend({ } if (forceRedirectedHere) { - await this.$router.push({ name: this.isDemoTest ? VIEWS.HOMEPAGE : VIEWS.NEW_WORKFLOW }); + await this.$router.push({ name: VIEWS.NEW_WORKFLOW }); } else { await this.$router.push({ name: VIEWS.USERS_SETTINGS }); } @@ -187,7 +184,7 @@ export default mixins(showMessage, restApi).extend({ onSkip() { this.usersStore.skipOwnerSetup(); this.$router.push({ - name: this.isDemoTest ? VIEWS.HOMEPAGE : VIEWS.NEW_WORKFLOW, + name: VIEWS.NEW_WORKFLOW, }); }, }, diff --git a/packages/editor-ui/src/views/WorkflowsView.vue b/packages/editor-ui/src/views/WorkflowsView.vue index 4d718b1c2ccf9..5311c1c6bb9e4 100644 --- a/packages/editor-ui/src/views/WorkflowsView.vue +++ b/packages/editor-ui/src/views/WorkflowsView.vue @@ -11,7 +11,7 @@ @click:add="addWorkflow" @update:filters="filters = $event" > -
{{ $locale.baseText('executionsList.name') }} @@ -212,7 +212,7 @@
{{ $locale.baseText('executionsList.empty') }}
@@ -228,17 +228,17 @@ :label="$locale.baseText('executionsList.loadMore')" @click="loadMore()" :loading="isDataLoading" - data-testid="load-more-button" + data-test-id="load-more-button" /> -
+
{{ $locale.baseText('executionsList.loadedAll') }}
{{ @@ -252,13 +252,13 @@ :label="$locale.baseText('generic.delete')" type="tertiary" @click="handleDeleteSelected" - data-testid="delete-selected-button" + data-test-id="delete-selected-button" />
diff --git a/packages/editor-ui/src/components/__tests__/SSOLogin.test.ts b/packages/editor-ui/src/components/__tests__/SSOLogin.test.ts index fd09c860e6126..3c168c521d1fc 100644 --- a/packages/editor-ui/src/components/__tests__/SSOLogin.test.ts +++ b/packages/editor-ui/src/components/__tests__/SSOLogin.test.ts @@ -19,7 +19,7 @@ const renderComponent = (renderOptions: Parameters[1] = {}) => pinia, stubs: { 'n8n-button': { - template: '', + template: '', }, }, }, diff --git a/packages/editor-ui/src/views/SettingsSso.vue b/packages/editor-ui/src/views/SettingsSso.vue index cd58958f386f4..ade3504554028 100644 --- a/packages/editor-ui/src/views/SettingsSso.vue +++ b/packages/editor-ui/src/views/SettingsSso.vue @@ -112,7 +112,7 @@ onBeforeMount(async () => { -
+
{ :disabled="!ssoSettingsSaved" type="tertiary" @click="onTest" - data-testid="sso-test" + data-test-id="sso-test" > {{ locale.baseText('settings.sso.settings.test') }} - + {{ locale.baseText('settings.sso.settings.save') }}
[1] = {}) => pinia: createTestingPinia(), stubs: { SSOLogin: { - template: '
', + template: '
', }, }, }, From b13b7d73e7857fe9a264d9400adfa337907f659a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 14 Apr 2023 13:33:27 +0200 Subject: [PATCH 11/30] fix(core): Fix paired item returning wrong data (#5898) * :bug: Fix paired item returning wrong data * :test_tube: Add e2e test * :rewind: Restore injection for simulated execution --- cypress/e2e/13-pinning.cy.ts | 39 ++++++++++++++++++- .../editor-ui/src/mixins/workflowHelpers.ts | 29 +------------- packages/workflow/src/WorkflowDataProxy.ts | 9 +---- 3 files changed, 40 insertions(+), 37 deletions(-) diff --git a/cypress/e2e/13-pinning.cy.ts b/cypress/e2e/13-pinning.cy.ts index 8a3ea4a7c8162..6f1c328fd9c6b 100644 --- a/cypress/e2e/13-pinning.cy.ts +++ b/cypress/e2e/13-pinning.cy.ts @@ -1,3 +1,9 @@ +import { + HTTP_REQUEST_NODE_NAME, + MANUAL_TRIGGER_NODE_DISPLAY_NAME, + PIPEDRIVE_NODE_NAME, + SET_NODE_NAME, +} from '../constants'; import { WorkflowPage, NDV } from '../pages'; const workflowPage = new WorkflowPage(); @@ -44,7 +50,7 @@ describe('Data pinning', () => { }); }); - it('Should be be able to set pinned data', () => { + it('Should be able to set pinned data', () => { workflowPage.actions.addInitialNodeToCanvas('Schedule Trigger', { keepNdvOpen: true }); ndv.getters.container().should('be.visible'); ndv.getters.pinDataButton().should('not.exist'); @@ -67,4 +73,35 @@ describe('Data pinning', () => { ndv.getters.outputTableHeaders().first().should('include.text', 'test'); ndv.getters.outputTbodyCell(1, 0).should('include.text', 1); }); + + it('Should be able to reference paired items in a node located before pinned data', () => { + workflowPage.actions.addInitialNodeToCanvas(MANUAL_TRIGGER_NODE_DISPLAY_NAME); + workflowPage.actions.addNodeToCanvas(HTTP_REQUEST_NODE_NAME, true, true); + ndv.actions.setPinnedData([{ http: 123 }]); + ndv.actions.close(); + + workflowPage.actions.addNodeToCanvas(PIPEDRIVE_NODE_NAME, true, true); + ndv.actions.setPinnedData(Array(3).fill({ pipedrive: 123 })); + ndv.actions.close(); + + workflowPage.actions.addNodeToCanvas(SET_NODE_NAME, true, true); + setExpressionOnStringValueInSet(`{{ $('${HTTP_REQUEST_NODE_NAME}').item`); + + const output = '[Object: {"json": {"http": 123}, "pairedItem": {"item": 0}}]'; + + cy.get('div').contains(output).should('be.visible'); + }); }); + +function setExpressionOnStringValueInSet(expression: string) { + cy.get('button').contains('Execute node').click(); + cy.get('input[placeholder="Add Value"]').click(); + cy.get('span').contains('String').click(); + + ndv.getters.nthParam(3).contains('Expression').invoke('show').click(); + + ndv.getters + .inlineExpressionEditorInput() + .clear() + .type(expression, { parseSpecialCharSequences: false }); +} diff --git a/packages/editor-ui/src/mixins/workflowHelpers.ts b/packages/editor-ui/src/mixins/workflowHelpers.ts index e3bdecac32d5c..c4037e6d62b77 100644 --- a/packages/editor-ui/src/mixins/workflowHelpers.ts +++ b/packages/editor-ui/src/mixins/workflowHelpers.ts @@ -140,33 +140,6 @@ export function resolveParameter( runExecutionData = executionData.data; } - parentNode.forEach((parentNodeName) => { - const pinData: IPinData[string] | undefined = - useWorkflowsStore().pinDataByNodeName(parentNodeName); - - if (pinData) { - runExecutionData = { - ...runExecutionData, - resultData: { - ...runExecutionData.resultData, - runData: { - ...runExecutionData.resultData.runData, - [parentNodeName]: [ - { - startTime: new Date().valueOf(), - executionTime: 0, - source: [], - data: { - main: [pinData.map((data) => ({ json: data }))], - }, - }, - ], - }, - }, - }; - } - }); - if (_connectionInputData === null) { _connectionInputData = []; } @@ -254,7 +227,7 @@ function getWorkflow(nodes: INodeUi[], connections: IConnections, copyData?: boo nodeTypes, settings: useWorkflowsStore().workflowSettings, // @ts-ignore - pinData: useWorkflowsStore().pinData, + pinData: useWorkflowsStore().getPinData, }); return cachedWorkflow; diff --git a/packages/workflow/src/WorkflowDataProxy.ts b/packages/workflow/src/WorkflowDataProxy.ts index b90b21a134072..48567826ae58d 100644 --- a/packages/workflow/src/WorkflowDataProxy.ts +++ b/packages/workflow/src/WorkflowDataProxy.ts @@ -719,18 +719,11 @@ export class WorkflowDataProxy { }; } - const previousNodeHasPinData = - sourceData && this.workflow.getPinDataOfNode(sourceData.previousNode) !== undefined; - let currentPairedItem = pairedItem; let nodeBeforeLast: string | undefined; - while ( - !previousNodeHasPinData && - sourceData !== null && - destinationNodeName !== sourceData.previousNode - ) { + while (sourceData !== null && destinationNodeName !== sourceData.previousNode) { taskData = that.runExecutionData!.resultData.runData[sourceData.previousNode][ sourceData?.previousNodeRun || 0 From 7c474d3c92ecca8e44e8eea76ada69aa7e8f5987 Mon Sep 17 00:00:00 2001 From: OlegIvaniv Date: Fri, 14 Apr 2023 13:33:58 +0200 Subject: [PATCH 12/30] fix(editor): Make sure to redirect to blank canvas after personalisation modal (#5980) * fix(editor): Make sure to redirect to blank canvas after personalization modal * Linting fix --- .../editor-ui/src/components/PersonalizationModal.vue | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/editor-ui/src/components/PersonalizationModal.vue b/packages/editor-ui/src/components/PersonalizationModal.vue index 863278447694c..7d38d9de97793 100644 --- a/packages/editor-ui/src/components/PersonalizationModal.vue +++ b/packages/editor-ui/src/components/PersonalizationModal.vue @@ -122,7 +122,8 @@ import { REPORTED_SOURCE_EVENT, REPORTED_SOURCE_OTHER, REPORTED_SOURCE_OTHER_KEY, -} from '../constants'; + VIEWS, +} from '@/constants'; import { workflowHelpers } from '@/mixins/workflowHelpers'; import { showMessage } from '@/mixins/showMessage'; import Modal from './Modal.vue'; @@ -606,6 +607,11 @@ export default mixins(showMessage, workflowHelpers).extend({ methods: { closeDialog() { this.modalBus.emit('close'); + // In case the redirect to canvas for new users didn't happen + // we try again after closing the modal + if (this.$route.name !== VIEWS.NEW_WORKFLOW) { + this.$router.replace({ name: VIEWS.NEW_WORKFLOW }); + } }, onSave() { this.formBus.emit('submit'); From 0776257490527cc196594828c3605090b49740e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Fri, 14 Apr 2023 13:56:12 +0200 Subject: [PATCH 13/30] ci: Add a workflow to push n8n releases to release channels (no-changelog) (#5839) add a workflow to push n8n releases to release channels --- .github/workflows/release-push-to-channel.yml | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/release-push-to-channel.yml diff --git a/.github/workflows/release-push-to-channel.yml b/.github/workflows/release-push-to-channel.yml new file mode 100644 index 0000000000000..7e79cc27e89fd --- /dev/null +++ b/.github/workflows/release-push-to-channel.yml @@ -0,0 +1,42 @@ +name: 'Release: Push to Channel' + +on: + workflow_dispatch: + inputs: + version: + description: 'n8n Release version to push to a channel' + required: true + + release-channel: + description: 'Release channel' + required: true + type: choice + default: 'next' + options: + - next + - latest + +jobs: + release-to-npm: + name: Release to NPM + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/setup-node@v3 + with: + node-version: 16.x + - run: | + echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc + npm dist-tag add n8n@${{ github.event.inputs.version }} ${{ github.event.inputs.release-channel }} + + release-to-docker-hub: + name: Release to DockerHub + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - run: docker buildx imagetools create -t n8nio/n8n:${{ github.event.inputs.release-channel }} n8nio/n8n:${{ github.event.inputs.version }} From 18d5156994dc0fe76fddaeb27471cef50f27d6b2 Mon Sep 17 00:00:00 2001 From: Filipe Dobreira Date: Fri, 14 Apr 2023 08:22:41 -0400 Subject: [PATCH 14/30] fix(IF Node): Fix typo in combine description (no-changelog) (#5964) --- packages/core/test/Helpers.ts | 4 ++-- packages/nodes-base/nodes/If/If.node.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/test/Helpers.ts b/packages/core/test/Helpers.ts index 8c4cb22a8bf70..c38362b8278a6 100644 --- a/packages/core/test/Helpers.ts +++ b/packages/core/test/Helpers.ts @@ -287,12 +287,12 @@ class NodeTypesClass implements INodeTypes { options: [ { name: 'ALL', - description: 'Only if all conditions are meet it goes into "true" branch.', + description: 'Only if all conditions are met it goes into "true" branch.', value: 'all', }, { name: 'ANY', - description: 'If any of the conditions is meet it goes into "true" branch.', + description: 'If any of the conditions is met it goes into "true" branch.', value: 'any', }, ], diff --git a/packages/nodes-base/nodes/If/If.node.ts b/packages/nodes-base/nodes/If/If.node.ts index f437e8934e6f1..96b016f74b9c5 100644 --- a/packages/nodes-base/nodes/If/If.node.ts +++ b/packages/nodes-base/nodes/If/If.node.ts @@ -290,12 +290,12 @@ export class If implements INodeType { options: [ { name: 'ALL', - description: 'Only if all conditions are meet it goes into "true" branch', + description: 'Only if all conditions are met it goes into "true" branch', value: 'all', }, { name: 'ANY', - description: 'If any of the conditions is meet it goes into "true" branch', + description: 'If any of the conditions is met it goes into "true" branch', value: 'any', }, ], From 4c994faec1ed6173d99f5b01efd9678e54e7eb49 Mon Sep 17 00:00:00 2001 From: Michael Auerswald Date: Fri, 14 Apr 2023 15:49:10 +0200 Subject: [PATCH 15/30] fix(core): Improve SAML connection test result views (#5981) * improve test result views * refactor * lint fix --- .../src/sso/saml/routes/saml.controller.ee.ts | 25 +++++++---- packages/cli/src/sso/saml/saml.service.ee.ts | 19 +++++---- packages/cli/src/sso/saml/samlHelpers.ts | 6 +++ .../saml/views/samlConnectionTestFailed.ts | 42 +++++++++++++++++++ .../saml/views/samlConnectionTestSuccess.ts | 33 +++++++++++++++ 5 files changed, 109 insertions(+), 16 deletions(-) create mode 100644 packages/cli/src/sso/saml/views/samlConnectionTestFailed.ts create mode 100644 packages/cli/src/sso/saml/views/samlConnectionTestSuccess.ts diff --git a/packages/cli/src/sso/saml/routes/saml.controller.ee.ts b/packages/cli/src/sso/saml/routes/saml.controller.ee.ts index 7ced030b0f579..86e7b75cfa8c4 100644 --- a/packages/cli/src/sso/saml/routes/saml.controller.ee.ts +++ b/packages/cli/src/sso/saml/routes/saml.controller.ee.ts @@ -13,7 +13,7 @@ import { getInitSSOFormView } from '../views/initSsoPost'; import { issueCookie } from '@/auth/jwt'; import { validate } from 'class-validator'; import type { PostBindingContext } from 'samlify/types/src/entity'; -import { isSamlLicensedAndEnabled } from '../samlHelpers'; +import { isConnectionTestRequest, isSamlLicensedAndEnabled } from '../samlHelpers'; import type { SamlLoginBinding } from '../types'; import { AuthenticatedRequest } from '@/requests'; import { @@ -21,6 +21,8 @@ import { getServiceProviderEntityId, getServiceProviderReturnUrl, } from '../serviceProvider.ee'; +import { getSamlConnectionTestSuccessView } from '../views/samlConnectionTestSuccess'; +import { getSamlConnectionTestFailedView } from '../views/samlConnectionTestFailed'; @RestController('/sso/saml') export class SamlController { @@ -106,11 +108,15 @@ export class SamlController { res: express.Response, binding: SamlLoginBinding, ) { - const loginResult = await this.samlService.handleSamlLogin(req, binding); - if (loginResult) { - // return attributes if this is a test connection - if (req.body.RelayState && req.body.RelayState === getServiceProviderConfigTestReturnUrl()) { - return res.status(202).send(loginResult.attributes); + try { + const loginResult = await this.samlService.handleSamlLogin(req, binding); + // if RelayState is set to the test connection Url, this is a test connection + if (isConnectionTestRequest(req)) { + if (loginResult.authenticatedUser) { + return res.send(getSamlConnectionTestSuccessView(loginResult.attributes)); + } else { + return res.send(getSamlConnectionTestFailedView('', loginResult.attributes)); + } } if (loginResult.authenticatedUser) { // Only sign in user if SAML is enabled, otherwise treat as test connection @@ -125,8 +131,13 @@ export class SamlController { return res.status(202).send(loginResult.attributes); } } + throw new AuthError('SAML Authentication failed'); + } catch (error) { + if (isConnectionTestRequest(req)) { + return res.send(getSamlConnectionTestFailedView((error as Error).message)); + } + throw new AuthError('SAML Authentication failed: ' + (error as Error).message); } - throw new AuthError('SAML Authentication failed'); } /** diff --git a/packages/cli/src/sso/saml/saml.service.ee.ts b/packages/cli/src/sso/saml/saml.service.ee.ts index 22909f46c9d71..846c7541bc827 100644 --- a/packages/cli/src/sso/saml/saml.service.ee.ts +++ b/packages/cli/src/sso/saml/saml.service.ee.ts @@ -139,14 +139,11 @@ export class SamlService { async handleSamlLogin( req: express.Request, binding: SamlLoginBinding, - ): Promise< - | { - authenticatedUser: User | undefined; - attributes: SamlUserAttributes; - onboardingRequired: boolean; - } - | undefined - > { + ): Promise<{ + authenticatedUser: User | undefined; + attributes: SamlUserAttributes; + onboardingRequired: boolean; + }> { const attributes = await this.getAttributesFromLoginResponse(req, binding); if (attributes.email) { const user = await Db.collections.User.findOne({ @@ -187,7 +184,11 @@ export class SamlService { } } } - return undefined; + return { + authenticatedUser: undefined, + attributes, + onboardingRequired: false, + }; } async setSamlPreferences(prefs: SamlPreferences): Promise { diff --git a/packages/cli/src/sso/saml/samlHelpers.ts b/packages/cli/src/sso/saml/samlHelpers.ts index 55efbedd6c839..0672965deb8b0 100644 --- a/packages/cli/src/sso/saml/samlHelpers.ts +++ b/packages/cli/src/sso/saml/samlHelpers.ts @@ -18,6 +18,8 @@ import { isSamlCurrentAuthenticationMethod, setCurrentAuthenticationMethod, } from '../ssoHelpers'; +import { getServiceProviderConfigTestReturnUrl } from './serviceProvider.ee'; +import type { SamlConfiguration } from './types/requests'; /** * Check whether the SAML feature is licensed and enabled in the instance */ @@ -173,3 +175,7 @@ export function getMappedSamlAttributesFromFlowResult( } return result; } + +export function isConnectionTestRequest(req: SamlConfiguration.AcsRequest): boolean { + return req.body.RelayState === getServiceProviderConfigTestReturnUrl(); +} diff --git a/packages/cli/src/sso/saml/views/samlConnectionTestFailed.ts b/packages/cli/src/sso/saml/views/samlConnectionTestFailed.ts new file mode 100644 index 0000000000000..8d9a3578f0635 --- /dev/null +++ b/packages/cli/src/sso/saml/views/samlConnectionTestFailed.ts @@ -0,0 +1,42 @@ +import type { SamlUserAttributes } from '../types/samlUserAttributes'; + +export function getSamlConnectionTestFailedView( + message: string, + attributes?: SamlUserAttributes, +): string { + return ` + + + n8n - SAML Connection Test Result + + + +
+

SAML Connection Test failed

+

${message ?? 'A common issue could be that no email attribute is set'}

+ +

+ ${ + attributes + ? ` +

Here are the attributes returned by your SAML IdP:

+
    +
  • Email: ${attributes?.email ?? '(n/a)'}
  • +
  • First Name: ${attributes?.firstName ?? '(n/a)'}
  • +
  • Last Name: ${attributes?.lastName ?? '(n/a)'}
  • +
  • UPN: ${attributes?.userPrincipalName ?? '(n/a)'}
  • +
` + : '' + } +
+ +
+ `; +} diff --git a/packages/cli/src/sso/saml/views/samlConnectionTestSuccess.ts b/packages/cli/src/sso/saml/views/samlConnectionTestSuccess.ts new file mode 100644 index 0000000000000..59e6aed263a0c --- /dev/null +++ b/packages/cli/src/sso/saml/views/samlConnectionTestSuccess.ts @@ -0,0 +1,33 @@ +import type { SamlUserAttributes } from '../types/samlUserAttributes'; + +export function getSamlConnectionTestSuccessView(attributes: SamlUserAttributes): string { + return ` + + + n8n - SAML Connection Test Result + + + +
+

SAML Connection Test was successful

+ +

+

Here are the attributes returned by your SAML IdP:

+
    +
  • Email: ${attributes.email ?? '(n/a)'}
  • +
  • First Name: ${attributes.firstName ?? '(n/a)'}
  • +
  • Last Name: ${attributes.lastName ?? '(n/a)'}
  • +
  • UPN: ${attributes.userPrincipalName ?? '(n/a)'}
  • +
+
+ +
+ `; +} From 026d0bc909e27cc82c8b533055ee53be57bcb372 Mon Sep 17 00:00:00 2001 From: Cornelius Suermann Date: Sun, 16 Apr 2023 16:51:48 +0200 Subject: [PATCH 16/30] ci: Add checklist item hinting at not adding new dependencies for nodes (no-changelog) (#5985) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: Add checklist item hinting at not adding new dependencies for nodes * Update CHECKLIST.yml Co-authored-by: Iván Ovejero --------- Co-authored-by: Iván Ovejero --- CHECKLIST.yml | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/CHECKLIST.yml b/CHECKLIST.yml index 7da0dcb08cbb1..a1c3fa8407508 100644 --- a/CHECKLIST.yml +++ b/CHECKLIST.yml @@ -1,48 +1,50 @@ paths: - "packages/**": + 'packages/**': - If fixing bug, added test to cover scenario. - If addressing forum or Github issue, added link to description. - "packages/**/*.ts": + 'packages/**/*.ts': - Added unit tests to cover new or updated functionality. - "**/*.vue": + '**/*.vue': - Used composition API for all new components. - Added component or unit tests to cover functionality. - + # cli - "packages/cli/src/databases/migrations/**": - - Requested review from at least two engineers on migration. + 'packages/cli/src/databases/migrations/**': + - Requested review from at least two engineers on migration. - Avoided irreversible data migrations. - Avoided deleting or updating data keys. - Wrote 'down' migration if possible. - "n8n/packages/cli/src/api/**": + 'n8n/packages/cli/src/api/**': - Added integration tests for new endpoints. # editor ui - "packages/editor-ui/**/*.vue": + 'packages/editor-ui/**/*.vue': - Added E2E if adding new features. - Used design system tokens (colors, spacings...) where possible. - "packages/editor-ui/src/mixins/restApi.ts": + 'packages/editor-ui/src/mixins/restApi.ts': - Avoided adding new methods. Only deleted from here. - "packages/editor-ui/src/mixins/**": + 'packages/editor-ui/src/mixins/**': - Avoided adding new mixins (use composables instead). Only removed code from here. - "packages/editor-ui/src/views/NodeView.vue": - - Avoided adding code here. Only refactored to make it smaller. - "packages/editor-ui/src/hooks/**": + 'packages/editor-ui/src/views/NodeView.vue': + - Avoided adding code here. Only refactored to make it smaller. + 'packages/editor-ui/src/hooks/**': - Avoided adding new hooks. Only refactored to move hooks to relevant store instead. # nodes-base - "packages/nodes-base/nodes/**": + 'packages/nodes-base/nodes/**': - Added workflow tests for nodes if possible. + 'packages/nodes-base/package.json': + - Avoided adding dependencies for nodes if not absolutely necessary. # design-system - "packages/design-system/**/*.vue": + 'packages/design-system/**/*.vue': - Used design system tokens (colors, spacings...) where possible. - Updated Storybook with new component or updated functionality. # e2e - "cypress/e2e/**": + 'cypress/e2e/**': - Avoided chaining commands more than two or three times (to avoid flakiness because only last one will be retried). - Spoofed endpoints that are not critical for the test (to avoid flakiness). - Picked most efficient path to start the test (for example skipped account setup and starting at /workflow/new for a canvas test). - Avoided adding waits on time (use request intercepts instead). - - Ensured each spec does not depend on any another spec to pass. \ No newline at end of file + - Ensured each spec does not depend on any another spec to pass. From c42820e82efe7365b5d7344bb3f474ba420ea7c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Mon, 17 Apr 2023 10:11:26 +0200 Subject: [PATCH 17/30] fix(core): Make `getExecutionId` available on all nodes types (#5990) fixes https://community.n8n.io/t/this-getexecutionid-in-custom-node-development-not-available-anymore/25259/1 --- packages/core/src/NodeExecuteFunctions.ts | 2 +- packages/workflow/src/Interfaces.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 1962b38d3dbe5..053a0423a3b05 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -2020,6 +2020,7 @@ const getCommonWorkflowFunctions = ( additionalData: IWorkflowExecuteAdditionalData, ): Omit => ({ logger: Logger, + getExecutionId: () => additionalData.executionId!, getNode: () => deepCopy(node), getWorkflow: () => ({ id: workflow.id, @@ -2325,7 +2326,6 @@ export function getExecuteFunctions( getContext(type: string): IContextObject { return NodeHelpers.getContext(runExecutionData, type, node); }, - getExecutionId: () => additionalData.executionId!, getInputData: (inputIndex = 0, inputName = 'main') => { if (!inputData.hasOwnProperty(inputName)) { // Return empty array because else it would throw error when nothing is connected to input diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 5098b48e5cdeb..2aed293657961 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -719,6 +719,7 @@ export interface RequestHelperFunctions { export interface FunctionsBase { logger: ILogger; getCredentials(type: string, itemIndex?: number): Promise; + getExecutionId(): string; getNode(): INode; getWorkflow(): IWorkflowMetadata; getWorkflowStaticData(type: string): IDataObject; From ba5b4eb42fa1b609ddbe726d3e8655c1f9d28a2e Mon Sep 17 00:00:00 2001 From: Michael Kret <88898367+michael-radency@users.noreply.github.com> Date: Mon, 17 Apr 2023 16:23:57 +0300 Subject: [PATCH 18/30] fix(Google Sheets Trigger Node): Return actual error message --- .../nodes-base/nodes/Google/Sheet/v2/transport/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/nodes-base/nodes/Google/Sheet/v2/transport/index.ts b/packages/nodes-base/nodes/Google/Sheet/v2/transport/index.ts index f55ee6f942261..226d2256b408e 100644 --- a/packages/nodes-base/nodes/Google/Sheet/v2/transport/index.ts +++ b/packages/nodes-base/nodes/Google/Sheet/v2/transport/index.ts @@ -133,9 +133,9 @@ export async function apiRequest( } if (error.message.includes('PERMISSION_DENIED')) { - const message = 'Missing permissions for Google Sheet'; - const description = - "Please check that the account you're using has the right permissions. (If you're trying to modify the sheet, you'll need edit access.)"; + const message = `Missing permissions for Google Sheet, ${error.message}}`; + const details = error.description ? ` Details of the error: ${error.description}.` : ''; + const description = `Please check that the account you're using has the right permissions. (If you're trying to modify the sheet, you'll need edit access.)${details}`; throw new NodeApiError(this.getNode(), error as JsonObject, { message, description }); } From a3664de3556f9f8159ed310a289ec12a4cd2c5c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Mon, 17 Apr 2023 16:43:39 +0200 Subject: [PATCH 19/30] fix(Code Node): Handle user code returning `null` and `undefined` (#5989) fixes https://community.n8n.io/t/cannot-convert-undefined-or-null-to-object/25240 --- packages/nodes-base/nodes/Code/utils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/nodes-base/nodes/Code/utils.ts b/packages/nodes-base/nodes/Code/utils.ts index 67726f14858ed..3899a8be769ce 100644 --- a/packages/nodes-base/nodes/Code/utils.ts +++ b/packages/nodes-base/nodes/Code/utils.ts @@ -13,6 +13,7 @@ function isTraversable(maybe: unknown): maybe is IDataObject { */ export function standardizeOutput(output: IDataObject) { function standardizeOutputRecursive(obj: IDataObject, knownObjects = new WeakSet()): IDataObject { + if (obj === undefined || obj === null) return obj; for (const [key, value] of Object.entries(obj)) { if (!isTraversable(value)) continue; From 6b9909bd80f4b04ec877fe7ee9b8a2619392d220 Mon Sep 17 00:00:00 2001 From: Eddy Hernandez Date: Mon, 17 Apr 2023 08:03:34 -0700 Subject: [PATCH 20/30] fix(OpenAI Node): Update OpenAI Text Moderate input placeholder text (#5823) --- packages/nodes-base/nodes/OpenAi/TextDescription.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nodes-base/nodes/OpenAi/TextDescription.ts b/packages/nodes-base/nodes/OpenAi/TextDescription.ts index 0065cd008a0d7..83b42a993f67b 100644 --- a/packages/nodes-base/nodes/OpenAi/TextDescription.ts +++ b/packages/nodes-base/nodes/OpenAi/TextDescription.ts @@ -247,7 +247,7 @@ const moderateOperations: INodeProperties[] = [ displayName: 'Input', name: 'input', type: 'string', - placeholder: 'e.g. I want to kill them', + placeholder: 'e.g. My cat is adorable ❤️❤️', description: 'The input text to classify', displayOptions: { show: { From 6b1473c5f38cc061a9bd3724f1c6c255d0309be9 Mon Sep 17 00:00:00 2001 From: Cornelius Suermann Date: Mon, 17 Apr 2023 17:32:41 +0200 Subject: [PATCH 21/30] build: Update license SDK to v2.1.0 (no-changelog) (#5987) build: Update to license SDK v2.1.0 (no-changelog) --- packages/cli/package.json | 2 +- pnpm-lock.yaml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 12bc16a39474e..14c26f6d08df6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -115,7 +115,7 @@ "tsconfig-paths": "^4.1.2" }, "dependencies": { - "@n8n_io/license-sdk": "~2.0.0", + "@n8n_io/license-sdk": "~2.1.0", "@oclif/command": "^1.8.16", "@oclif/core": "^1.16.4", "@oclif/errors": "^1.3.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d6b7edc43658..783bd8a887b00 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -155,8 +155,8 @@ importers: packages/cli: dependencies: '@n8n_io/license-sdk': - specifier: ~2.0.0 - version: 2.0.0 + specifier: ~2.1.0 + version: 2.1.0 '@oclif/command': specifier: ^1.8.16 version: 1.8.18(@oclif/config@1.18.5)(supports-color@8.1.1) @@ -4288,8 +4288,8 @@ packages: dev: false optional: true - /@n8n_io/license-sdk@2.0.0: - resolution: {integrity: sha512-JBgVqp2Hp8QkEHwWmDvoU8dLrq0wZlK38FnwaX/yZiCGpEz1+zywFxaggxbDYXxvhX3cwlgxv3o3kfVaYX1Xlw==} + /@n8n_io/license-sdk@2.1.0: + resolution: {integrity: sha512-SwIm9b6a30/fAvl1aY0a6cgoSyQBgKHX44M4Ykesn45VSGBKlzO5uuIiIcEPdVjjLEelm7u6wLoDFdIVG37b7Q==} engines: {node: '>=14.0.0', npm: '>=7.10.0'} dependencies: crypto-js: 4.1.1 From 3810039da032ecbd038255316b3d8fa5ce5ef2df Mon Sep 17 00:00:00 2001 From: Bram Kn Date: Tue, 18 Apr 2023 10:19:35 +0200 Subject: [PATCH 22/30] feat(GitLab Node): Add Additional parameters for File List (#5621) --- .../nodes-base/nodes/Gitlab/Gitlab.node.ts | 39 +++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/nodes-base/nodes/Gitlab/Gitlab.node.ts b/packages/nodes-base/nodes/Gitlab/Gitlab.node.ts index a974bc5c3e312..421c5905cadd3 100644 --- a/packages/nodes-base/nodes/Gitlab/Gitlab.node.ts +++ b/packages/nodes-base/nodes/Gitlab/Gitlab.node.ts @@ -1080,6 +1080,38 @@ export class Gitlab implements INodeType { default: 1, description: 'Page of results to display', }, + { + displayName: 'Additional Parameters', + name: 'additionalParameters', + placeholder: 'Add Parameter', + description: 'Additional fields to add', + type: 'collection', + default: {}, + displayOptions: { + show: { + resource: ['file'], + operation: ['list'], + }, + }, + options: [ + { + displayName: 'Reference', + name: 'ref', + type: 'string', + default: '', + placeholder: 'main', + description: + 'The name of the commit/branch/tag. Default: the repository’s default branch (usually main).', + }, + { + displayName: 'Recursive', + name: 'recursive', + type: 'boolean', + default: false, + description: 'Whether or not to get a recursive file tree. Default is false.', + }, + ], + }, // ---------------------------------- // file:get @@ -1134,9 +1166,9 @@ export class Gitlab implements INodeType { name: 'reference', type: 'string', default: '', - placeholder: 'master', + placeholder: 'main', description: - 'The name of the commit/branch/tag. Default: the repository’s default branch (usually master).', + 'The name of the commit/branch/tag. Default: the repository’s default branch (usually main).', }, ], }, @@ -1635,7 +1667,8 @@ export class Gitlab implements INodeType { requestMethod = 'GET'; const filePath = this.getNodeParameter('filePath', i); - qs = this.getNodeParameter('additionalFields', i, {}); + + qs = this.getNodeParameter('additionalParameters', i, {}) as IDataObject; returnAll = this.getNodeParameter('returnAll', i); if (!returnAll) { From 9693142985e0ad8f451eac3b5a7b4f12a0e95142 Mon Sep 17 00:00:00 2001 From: OlegIvaniv Date: Tue, 18 Apr 2023 11:47:08 +0200 Subject: [PATCH 23/30] refactor(editor): Refactor history and debounce mixins to composables (no-changelog) (#5930) * refactor(editor): Refactor history and debounce mixins to composables and add unit tests (no-changelog) * Lint fix and use userEvent to fire keydown events * Fix debounce spec --- packages/editor-ui/src/App.vue | 10 +- .../composables/__tests__/useDebounce.test.ts | 72 +++++++++ .../__tests__/useHistoryHelper.test.ts | 94 ++++++++++++ .../editor-ui/src/composables/useDebounce.ts | 38 +++++ .../src/composables/useHistoryHelper.ts | 137 ++++++++++++++++++ packages/editor-ui/src/mixins/history.ts | 121 ---------------- 6 files changed, 346 insertions(+), 126 deletions(-) create mode 100644 packages/editor-ui/src/composables/__tests__/useDebounce.test.ts create mode 100644 packages/editor-ui/src/composables/__tests__/useHistoryHelper.test.ts create mode 100644 packages/editor-ui/src/composables/useDebounce.ts create mode 100644 packages/editor-ui/src/composables/useHistoryHelper.ts delete mode 100644 packages/editor-ui/src/mixins/history.ts diff --git a/packages/editor-ui/src/App.vue b/packages/editor-ui/src/App.vue index 035416a1dab15..b5baefe0673f6 100644 --- a/packages/editor-ui/src/App.vue +++ b/packages/editor-ui/src/App.vue @@ -45,10 +45,11 @@ import { useUsersStore } from './stores/users'; import { useRootStore } from './stores/n8nRootStore'; import { useTemplatesStore } from './stores/templates'; import { useNodeTypesStore } from './stores/nodeTypes'; -import { historyHelper } from '@/mixins/history'; +import { useHistoryHelper } from '@/composables/useHistoryHelper'; import { newVersions } from '@/mixins/newVersions'; +import { useRoute } from 'vue-router/composables'; -export default mixins(newVersions, showMessage, userHelpers, restApi, historyHelper).extend({ +export default mixins(newVersions, showMessage, userHelpers, restApi).extend({ name: 'App', components: { LoadingView, @@ -56,10 +57,9 @@ export default mixins(newVersions, showMessage, userHelpers, restApi, historyHel Modals, }, setup() { - const { registerCustomAction, unregisterCustomAction } = useGlobalLinkActions(); return { - registerCustomAction, - unregisterCustomAction, + ...useGlobalLinkActions(), + ...useHistoryHelper(useRoute()), }; }, computed: { diff --git a/packages/editor-ui/src/composables/__tests__/useDebounce.test.ts b/packages/editor-ui/src/composables/__tests__/useDebounce.test.ts new file mode 100644 index 0000000000000..fecac5f4a4bda --- /dev/null +++ b/packages/editor-ui/src/composables/__tests__/useDebounce.test.ts @@ -0,0 +1,72 @@ +import { vi, describe, it, expect } from 'vitest'; +import { useDebounceHelper } from '../useDebounce'; +import { render, screen } from '@testing-library/vue'; + +describe('useDebounceHelper', () => { + const debounceTime = 200; + + const TestComponent = { + template: ` +
+ + + +
+ `, + props: { + mockFn: { + type: Function, + }, + }, + setup() { + const { callDebounced } = useDebounceHelper(); + return { + callDebounced, + debounceTime, + }; + }, + }; + + it('debounces a function call', async () => { + const mockFn = vi.fn(); + render(TestComponent, { props: { mockFn } }); + const button = screen.getByText('Click me'); + + button.click(); + button.click(); + + expect(mockFn).toHaveBeenCalledTimes(1); + }); + + it('supports trailing option', async () => { + const mockFn = vi.fn(); + render(TestComponent, { props: { mockFn } }); + const button = screen.getByText('Click me trailing'); + + button.click(); + button.click(); + + expect(mockFn).toHaveBeenCalledTimes(0); + + await new Promise((resolve) => setTimeout(resolve, debounceTime)); + + expect(mockFn).toHaveBeenCalledTimes(1); + }); + + it('works with async functions', async () => { + const mockAsyncFn = vi.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + render(TestComponent, { props: { mockFn: mockAsyncFn } }); + const button = screen.getByText('Click me'); + + button.click(); + button.click(); + + expect(mockAsyncFn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/editor-ui/src/composables/__tests__/useHistoryHelper.test.ts b/packages/editor-ui/src/composables/__tests__/useHistoryHelper.test.ts new file mode 100644 index 0000000000000..e31753736886c --- /dev/null +++ b/packages/editor-ui/src/composables/__tests__/useHistoryHelper.test.ts @@ -0,0 +1,94 @@ +import { vi, describe, it, expect } from 'vitest'; +import { MAIN_HEADER_TABS } from '@/constants'; +import { render } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; +import { useHistoryHelper } from '../useHistoryHelper'; +import { defineComponent } from 'vue'; +import { Route } from 'vue-router'; + +const undoMock = vi.fn(); +const redoMock = vi.fn(); +vi.mock('@/stores/ndv', () => ({ + useNDVStore: () => ({ + activeNodeName: null, + activeNode: {}, + }), +})); +vi.mock('@/stores/history', () => { + return { + useHistoryStore: () => ({ + popUndoableToUndo: undoMock, + popUndoableToRedo: redoMock, + }), + }; +}); +vi.mock('@/stores/ui'); +vi.mock('vue-router/composables', () => ({ + useRoute: () => ({}), +})); + +const TestComponent = defineComponent({ + props: { + route: { + type: Object, + }, + }, + setup(props) { + useHistoryHelper(props.route as Route); + + return {}; + }, + template: '
', +}); + +describe('useHistoryHelper', () => { + beforeEach(() => { + undoMock.mockClear(); + redoMock.mockClear(); + }); + it('should call undo when Ctrl+Z is pressed', async () => { + // @ts-ignore + render(TestComponent, { + props: { + route: { + name: MAIN_HEADER_TABS.WORKFLOW, + meta: { + nodeView: true, + }, + }, + }, + }); + + await userEvent.keyboard('{Control>}z'); + await userEvent.keyboard('{Control>}z'); + + expect(undoMock).toHaveBeenCalledTimes(2); + }); + it('should call redo when Ctrl+Shift+Z is pressed', async () => { + // @ts-ignore + render(TestComponent, { + props: { + route: { + name: MAIN_HEADER_TABS.WORKFLOW, + meta: { + nodeView: true, + }, + }, + }, + }); + + await userEvent.keyboard('{Control>}{Shift>}z'); + await userEvent.keyboard('{Control>}{Shift>}z'); + + expect(redoMock).toHaveBeenCalledTimes(2); + }); + it('should not call undo when Ctrl+Z if not on NodeView', async () => { + // @ts-ignore + render(TestComponent, { props: { route: {} } }); + + await userEvent.keyboard('{Control>}z'); + await userEvent.keyboard('{Control>}z'); + + expect(undoMock).toHaveBeenCalledTimes(0); + }); +}); diff --git a/packages/editor-ui/src/composables/useDebounce.ts b/packages/editor-ui/src/composables/useDebounce.ts new file mode 100644 index 0000000000000..a5b4c403165cd --- /dev/null +++ b/packages/editor-ui/src/composables/useDebounce.ts @@ -0,0 +1,38 @@ +import { ref } from 'vue'; +import { debounce } from 'lodash-es'; + +type DebouncedFunction = (...args: unknown[]) => Promise | void; + +export function useDebounceHelper() { + // Create a ref for the WeakMap to store debounced functions. + const debouncedFunctions = ref(new WeakMap()); + + const callDebounced = async ( + func: DebouncedFunction, + options: { debounceTime: number; trailing?: boolean }, + ...inputParameters: unknown[] + ): Promise => { + const { trailing, debounceTime } = options; + + // Check if a debounced version of the function is already stored in the WeakMap. + let debouncedFunc = debouncedFunctions.value.get(func); + + // If a debounced version is not found, create one and store it in the WeakMap. + if (debouncedFunc === undefined) { + debouncedFunc = debounce( + async (...args: unknown[]) => { + await func(...args); + }, + debounceTime, + trailing ? { trailing } : { leading: true }, + ); + debouncedFunctions.value.set(func, debouncedFunc); + } + + await debouncedFunc(...inputParameters); + }; + + return { + callDebounced, + }; +} diff --git a/packages/editor-ui/src/composables/useHistoryHelper.ts b/packages/editor-ui/src/composables/useHistoryHelper.ts new file mode 100644 index 0000000000000..79fc5f6d72049 --- /dev/null +++ b/packages/editor-ui/src/composables/useHistoryHelper.ts @@ -0,0 +1,137 @@ +import { MAIN_HEADER_TABS } from '@/constants'; +import { useNDVStore } from '@/stores/ndv'; +import { BulkCommand, Undoable } from '@/models/history'; +import { useHistoryStore } from '@/stores/history'; +import { useUIStore } from '@/stores/ui'; + +import { ref, onMounted, onUnmounted, Ref, nextTick, getCurrentInstance } from 'vue'; +import { Command } from '@/models/history'; +import { useDebounceHelper } from './useDebounce'; +import useDeviceSupportHelpers from './useDeviceSupport'; +import { getNodeViewTab } from '@/utils'; +import { Route } from 'vue-router'; + +const UNDO_REDO_DEBOUNCE_INTERVAL = 100; + +export function useHistoryHelper(activeRoute: Route) { + const instance = getCurrentInstance(); + const telemetry = instance?.proxy.$telemetry; + + const ndvStore = useNDVStore(); + const historyStore = useHistoryStore(); + const uiStore = useUIStore(); + + const { callDebounced } = useDebounceHelper(); + const { isCtrlKeyPressed } = useDeviceSupportHelpers(); + + const isNDVOpen = ref(ndvStore.activeNodeName !== null); + + const undo = () => + callDebounced( + async () => { + const command = historyStore.popUndoableToUndo(); + if (!command) { + return; + } + if (command instanceof BulkCommand) { + historyStore.bulkInProgress = true; + const commands = command.commands; + const reverseCommands: Command[] = []; + for (let i = commands.length - 1; i >= 0; i--) { + await commands[i].revert(); + reverseCommands.push(commands[i].getReverseCommand()); + } + historyStore.pushUndoableToRedo(new BulkCommand(reverseCommands)); + await nextTick(); + historyStore.bulkInProgress = false; + } + if (command instanceof Command) { + await command.revert(); + historyStore.pushUndoableToRedo(command.getReverseCommand()); + uiStore.stateIsDirty = true; + } + trackCommand(command, 'undo'); + }, + { debounceTime: UNDO_REDO_DEBOUNCE_INTERVAL }, + ); + + const redo = () => + callDebounced( + async () => { + const command = historyStore.popUndoableToRedo(); + if (!command) { + return; + } + if (command instanceof BulkCommand) { + historyStore.bulkInProgress = true; + const commands = command.commands; + const reverseCommands = []; + for (let i = commands.length - 1; i >= 0; i--) { + await commands[i].revert(); + reverseCommands.push(commands[i].getReverseCommand()); + } + historyStore.pushBulkCommandToUndo(new BulkCommand(reverseCommands), false); + await nextTick(); + historyStore.bulkInProgress = false; + } + if (command instanceof Command) { + await command.revert(); + historyStore.pushCommandToUndo(command.getReverseCommand(), false); + uiStore.stateIsDirty = true; + } + trackCommand(command, 'redo'); + }, + { debounceTime: UNDO_REDO_DEBOUNCE_INTERVAL }, + ); + + function trackCommand(command: Undoable, type: 'undo' | 'redo'): void { + if (command instanceof Command) { + telemetry?.track(`User hit ${type}`, { commands_length: 1, commands: [command.name] }); + } else if (command instanceof BulkCommand) { + telemetry?.track(`User hit ${type}`, { + commands_length: command.commands.length, + commands: command.commands.map((c) => c.name), + }); + } + } + + function trackUndoAttempt(event: KeyboardEvent) { + if (isNDVOpen.value && !event.shiftKey) { + const activeNode = ndvStore.activeNode; + if (activeNode) { + telemetry?.track('User hit undo in NDV', { node_type: activeNode.type }); + } + } + } + + function handleKeyDown(event: KeyboardEvent) { + const currentNodeViewTab = getNodeViewTab(activeRoute); + + if (event.repeat || currentNodeViewTab !== MAIN_HEADER_TABS.WORKFLOW) return; + if (isCtrlKeyPressed(event) && event.key.toLowerCase() === 'z') { + event.preventDefault(); + if (!isNDVOpen.value) { + if (event.shiftKey) { + redo(); + } else { + undo(); + } + } else if (!event.shiftKey) { + trackUndoAttempt(event); + } + } + } + + onMounted(() => { + document.addEventListener('keydown', handleKeyDown); + }); + + onUnmounted(() => { + document.removeEventListener('keydown', handleKeyDown); + }); + + return { + undo, + redo, + }; +} diff --git a/packages/editor-ui/src/mixins/history.ts b/packages/editor-ui/src/mixins/history.ts deleted file mode 100644 index 0726c6a03d6fc..0000000000000 --- a/packages/editor-ui/src/mixins/history.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { MAIN_HEADER_TABS } from './../constants'; -import { useNDVStore } from '@/stores/ndv'; -import { BulkCommand, Undoable } from '@/models/history'; -import { useHistoryStore } from '@/stores/history'; -import { useUIStore } from '@/stores/ui'; -import { useWorkflowsStore } from '@/stores/workflows'; -import { mapStores } from 'pinia'; -import mixins from 'vue-typed-mixins'; -import { Command } from '@/models/history'; -import { debounceHelper } from '@/mixins/debounce'; -import { deviceSupportHelpers } from '@/mixins/deviceSupportHelpers'; -import Vue from 'vue'; -import { getNodeViewTab } from '@/utils'; - -const UNDO_REDO_DEBOUNCE_INTERVAL = 100; - -export const historyHelper = mixins(debounceHelper, deviceSupportHelpers).extend({ - computed: { - ...mapStores(useNDVStore, useHistoryStore, useUIStore, useWorkflowsStore), - isNDVOpen(): boolean { - return this.ndvStore.activeNodeName !== null; - }, - }, - mounted() { - document.addEventListener('keydown', this.handleKeyDown); - }, - destroyed() { - document.removeEventListener('keydown', this.handleKeyDown); - }, - methods: { - handleKeyDown(event: KeyboardEvent) { - const currentNodeViewTab = getNodeViewTab(this.$route); - - if (event.repeat || currentNodeViewTab !== MAIN_HEADER_TABS.WORKFLOW) return; - if (this.isCtrlKeyPressed(event) && event.key.toLowerCase() === 'z') { - event.preventDefault(); - if (!this.isNDVOpen) { - if (event.shiftKey) { - this.callDebounced('redo', { - debounceTime: UNDO_REDO_DEBOUNCE_INTERVAL, - trailing: true, - }); - } else { - this.callDebounced('undo', { - debounceTime: UNDO_REDO_DEBOUNCE_INTERVAL, - trailing: true, - }); - } - } else if (!event.shiftKey) { - this.trackUndoAttempt(event); - } - } - }, - async undo() { - const command = this.historyStore.popUndoableToUndo(); - if (!command) { - return; - } - if (command instanceof BulkCommand) { - this.historyStore.bulkInProgress = true; - const commands = command.commands; - const reverseCommands: Command[] = []; - for (let i = commands.length - 1; i >= 0; i--) { - await commands[i].revert(); - reverseCommands.push(commands[i].getReverseCommand()); - } - this.historyStore.pushUndoableToRedo(new BulkCommand(reverseCommands)); - await Vue.nextTick(); - this.historyStore.bulkInProgress = false; - } - if (command instanceof Command) { - await command.revert(); - this.historyStore.pushUndoableToRedo(command.getReverseCommand()); - this.uiStore.stateIsDirty = true; - } - this.trackCommand(command, 'undo'); - }, - async redo() { - const command = this.historyStore.popUndoableToRedo(); - if (!command) { - return; - } - if (command instanceof BulkCommand) { - this.historyStore.bulkInProgress = true; - const commands = command.commands; - const reverseCommands = []; - for (let i = commands.length - 1; i >= 0; i--) { - await commands[i].revert(); - reverseCommands.push(commands[i].getReverseCommand()); - } - this.historyStore.pushBulkCommandToUndo(new BulkCommand(reverseCommands), false); - await Vue.nextTick(); - this.historyStore.bulkInProgress = false; - } - if (command instanceof Command) { - await command.revert(); - this.historyStore.pushCommandToUndo(command.getReverseCommand(), false); - this.uiStore.stateIsDirty = true; - } - this.trackCommand(command, 'redo'); - }, - trackCommand(command: Undoable, type: 'undo' | 'redo'): void { - if (command instanceof Command) { - this.$telemetry.track(`User hit ${type}`, { commands_length: 1, commands: [command.name] }); - } else if (command instanceof BulkCommand) { - this.$telemetry.track(`User hit ${type}`, { - commands_length: command.commands.length, - commands: command.commands.map((c) => c.name), - }); - } - }, - trackUndoAttempt(event: KeyboardEvent) { - if (this.isNDVOpen && !event.shiftKey) { - const activeNode = this.ndvStore.activeNode; - if (activeNode) { - this.$telemetry.track('User hit undo in NDV', { node_type: activeNode.type }); - } - } - }, - }, -}); From b351c6265938a908f90237c834012cc19cf70dc3 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Tue, 18 Apr 2023 11:48:38 +0200 Subject: [PATCH 24/30] fix(HTTP Request Node): Fix itemIndex in HTTP Request errors (#5991) --- .../nodes-base/nodes/HttpRequest/V1/HttpRequestV1.node.ts | 2 +- .../nodes-base/nodes/HttpRequest/V2/HttpRequestV2.node.ts | 2 +- .../nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/nodes-base/nodes/HttpRequest/V1/HttpRequestV1.node.ts b/packages/nodes-base/nodes/HttpRequest/V1/HttpRequestV1.node.ts index 7b989d6611b4b..e5971875d686a 100644 --- a/packages/nodes-base/nodes/HttpRequest/V1/HttpRequestV1.node.ts +++ b/packages/nodes-base/nodes/HttpRequest/V1/HttpRequestV1.node.ts @@ -967,7 +967,7 @@ export class HttpRequestV1 implements INodeType { if (response!.status !== 'fulfilled') { if (!this.continueOnFail()) { // throw error; - throw new NodeApiError(this.getNode(), response as JsonObject); + throw new NodeApiError(this.getNode(), response as JsonObject, { itemIndex }); } else { // Return the actual reason as error returnItems.push({ diff --git a/packages/nodes-base/nodes/HttpRequest/V2/HttpRequestV2.node.ts b/packages/nodes-base/nodes/HttpRequest/V2/HttpRequestV2.node.ts index b136dbf1eb99c..69d9d3d11b52a 100644 --- a/packages/nodes-base/nodes/HttpRequest/V2/HttpRequestV2.node.ts +++ b/packages/nodes-base/nodes/HttpRequest/V2/HttpRequestV2.node.ts @@ -1019,7 +1019,7 @@ export class HttpRequestV2 implements INodeType { if (response!.status !== 'fulfilled') { if (!this.continueOnFail()) { // throw error; - throw new NodeApiError(this.getNode(), response as JsonObject); + throw new NodeApiError(this.getNode(), response as JsonObject, { itemIndex }); } else { // Return the actual reason as error returnItems.push({ diff --git a/packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts b/packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts index 0618bd957f9fc..f515b7ab2adb4 100644 --- a/packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts +++ b/packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts @@ -1176,7 +1176,7 @@ export class HttpRequestV3 implements INodeType { this.getNode(), 'JSON parameter need to be an valid JSON', { - runIndex: itemIndex, + itemIndex, }, ); } @@ -1242,7 +1242,7 @@ export class HttpRequestV3 implements INodeType { this.getNode(), 'JSON parameter need to be an valid JSON', { - runIndex: itemIndex, + itemIndex, }, ); } @@ -1264,7 +1264,7 @@ export class HttpRequestV3 implements INodeType { this.getNode(), 'JSON parameter need to be an valid JSON', { - runIndex: itemIndex, + itemIndex, }, ); } @@ -1381,7 +1381,7 @@ export class HttpRequestV3 implements INodeType { if (autoDetectResponseFormat && response.reason.error instanceof Buffer) { response.reason.error = Buffer.from(response.reason.error as Buffer).toString(); } - throw new NodeApiError(this.getNode(), response as JsonObject); + throw new NodeApiError(this.getNode(), response as JsonObject, { itemIndex }); } else { // Return the actual reason as error returnItems.push({ From 3c2a56928b46425822795cf1594133a538f47c21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 18 Apr 2023 12:04:26 +0200 Subject: [PATCH 25/30] fix(editor): Fix moving canvas on middle click preventing lasso selection (#5996) :bug: Prevent moving canvas on middle click --- packages/editor-ui/src/mixins/moveNodeWorkflow.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/editor-ui/src/mixins/moveNodeWorkflow.ts b/packages/editor-ui/src/mixins/moveNodeWorkflow.ts index c72264767c8b2..d35a46b8645d9 100644 --- a/packages/editor-ui/src/mixins/moveNodeWorkflow.ts +++ b/packages/editor-ui/src/mixins/moveNodeWorkflow.ts @@ -39,7 +39,7 @@ export const moveNodeWorkflow = mixins(deviceSupportHelpers).extend({ return; } - // Don't indicate move start just yet if middle button is pressed + // Prevent moving canvas on anything but middle button if (e.button !== 1) { this.uiStore.nodeViewMoveInProgress = true; } @@ -77,7 +77,7 @@ export const moveNodeWorkflow = mixins(deviceSupportHelpers).extend({ } // Signal that moving canvas is active if middle button is pressed and mouse is moved - if (e.button === 1) { + if (e.buttons === 4) { this.uiStore.nodeViewMoveInProgress = true; } From 1555387eced59e1169c9c95ef34e1ab954bbf92a Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 18 Apr 2023 11:28:05 +0100 Subject: [PATCH 26/30] ci: Improve test for wait node (#5997) --- .../nodes/Wait/test/Wait.node.test.ts | 17 +++- .../nodes/Wait/test/Wait.workflow.json | 99 +++++++++++-------- 2 files changed, 75 insertions(+), 41 deletions(-) diff --git a/packages/nodes-base/nodes/Wait/test/Wait.node.test.ts b/packages/nodes-base/nodes/Wait/test/Wait.node.test.ts index 0b08b1f11ac5b..02b5e39eac8c2 100644 --- a/packages/nodes-base/nodes/Wait/test/Wait.node.test.ts +++ b/packages/nodes-base/nodes/Wait/test/Wait.node.test.ts @@ -1,4 +1,19 @@ import { testWorkflows, getWorkflowFilenames } from '../../../test/nodes/Helpers'; const workflows = getWorkflowFilenames(__dirname); -describe('Execute Wait Node', () => testWorkflows(workflows)); +describe('Execute Wait Node', () => { + let timer: NodeJS.Timer; + const { clearInterval, setInterval } = global; + + beforeAll(() => { + timer = setInterval(() => jest.advanceTimersByTime(1000), 10); + jest.useFakeTimers(); + }); + + afterAll(() => { + clearInterval(timer); + jest.useRealTimers(); + }); + + testWorkflows(workflows); +}); diff --git a/packages/nodes-base/nodes/Wait/test/Wait.workflow.json b/packages/nodes-base/nodes/Wait/test/Wait.workflow.json index 1e777f5cc7dda..27fc8ff5abf7d 100644 --- a/packages/nodes-base/nodes/Wait/test/Wait.workflow.json +++ b/packages/nodes-base/nodes/Wait/test/Wait.workflow.json @@ -3,77 +3,86 @@ "nodes": [ { "parameters": {}, - "id": "d24d60c0-fe08-4c95-afc2-51b92890667b", + "id": "76e5dcfd-fdc7-472f-8832-bccc0eb122c0", "name": "When clicking \"Execute Workflow\"", "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, - "position": [500, 400] + "position": [120, 420] }, { "parameters": { - "amount": 2, + "amount": 42, "unit": "seconds" }, - "id": "244f011e-bf10-4745-9dac-1ef0a1ac8167", + "id": "37f2c758-6fb2-43ce-86ae-ca11ec957cbd", "name": "Wait", "type": "n8n-nodes-base.wait", "typeVersion": 1, - "position": [900, 400], + "position": [560, 420], "webhookId": "35edc971-c3e4-48cf-835d-4d73a4fd1fd8" }, { "parameters": { "conditions": { - "string": [ + "number": [ { - "value1": "={{ $now.toFormat('ss') }}", - "value2": "={{ $json.start }}" + "value1": "={{ parseInt($json.afterTimestamp) }}", + "operation": "largerEqual", + "value2": "={{ parseInt($json.startTimestamp) + 42 }}" } ] } }, - "id": "d5977620-7f73-4cbe-a354-03658462167f", + "id": "c5c53934-2677-4adf-a4df-b32f3b0642a2", "name": "IF", "type": "n8n-nodes-base.if", "typeVersion": 1, - "position": [1120, 400] + "position": [960, 420] }, { "parameters": { + "keepOnlySet": true, "values": { - "number": [ + "boolean": [ { - "name": "start", - "value": "={{ $now.plus({seconds: 2}).toFormat('ss') }}" + "name": "success", + "value": true } ] }, "options": {} }, - "id": "7f88ca0b-b743-414e-ae03-d85c0efcbece", - "name": "Set", + "id": "a78417b6-d3f5-4bbc-916a-d4b9d46961cc", + "name": "Set1", "type": "n8n-nodes-base.set", "typeVersion": 1, - "position": [700, 400] + "position": [1180, 400] }, { "parameters": { - "keepOnlySet": true, - "values": { - "boolean": [ - { - "name": "success", - "value": true - } - ] - }, + "value": "={{ $now }}", + "dataPropertyName": "afterTimestamp", + "toFormat": "X", "options": {} }, - "id": "d55dc8cf-7e5e-4282-854b-9047e179a06b", - "name": "Set1", - "type": "n8n-nodes-base.set", + "id": "94f042ea-49d5-44ea-9ccf-93dac8d27d4a", + "name": "After", + "type": "n8n-nodes-base.dateTime", + "typeVersion": 1, + "position": [760, 420] + }, + { + "parameters": { + "value": "={{ $now }}", + "dataPropertyName": "startTimestamp", + "toFormat": "X", + "options": {} + }, + "id": "43f8a396-1bf7-484e-962c-120f677dfa51", + "name": "Before", + "type": "n8n-nodes-base.dateTime", "typeVersion": 1, - "position": [1340, 380] + "position": [360, 420] } ], "pinData": { @@ -90,7 +99,7 @@ "main": [ [ { - "node": "Set", + "node": "Before", "type": "main", "index": 0 } @@ -101,43 +110,53 @@ "main": [ [ { - "node": "IF", + "node": "After", "type": "main", "index": 0 } ] ] }, - "Set": { + "IF": { "main": [ [ { - "node": "Wait", + "node": "Set1", "type": "main", "index": 0 } ] ] }, - "IF": { + "After": { "main": [ [ { - "node": "Set1", + "node": "IF", "type": "main", "index": 0 } - ], - [] + ] + ] + }, + "Before": { + "main": [ + [ + { + "node": "Wait", + "type": "main", + "index": 0 + } + ] ] } }, "active": false, "settings": {}, - "versionId": "1bd65d2e-3a90-478d-89ae-319ceed2dbf8", - "id": "391", + "versionId": "8ed794a0-5c04-4b8a-9a49-02c2c7f8003f", + "id": "500", "meta": { - "instanceId": "REMOVED" + "instanceId": "8c8c5237b8e37b006a7adce87f4369350c58e41f3ca9de16196d3197f69eabcd" }, "tags": [] } From 1bb987140af8e835770a0ca45403e274a793f22c Mon Sep 17 00:00:00 2001 From: Val <68596159+valya@users.noreply.github.com> Date: Tue, 18 Apr 2023 11:41:55 +0100 Subject: [PATCH 27/30] feat: Add variables feature (#5602) * feat: add variables db models and migrations * feat: variables api endpoints * feat: add $variables to expressions * test: fix ActiveWorkflowRunner tests failing * test: a different fix for the tests broken by $variables * feat: variables licensing * fix: could create one extra variable than licensed for * feat: Add Variables UI page and $vars global property (#5750) * feat: add support for row slot to datatable * feat: add variables create, read, update, delete * feat: add vars autocomplete * chore: remove alert * feat: add variables autocomplete for code and expressions * feat: add tests for variable components * feat: add variables search and sort * test: update tests for variables view * chore: fix test and linting issue * refactor: review changes * feat: add variable creation telemetry * fix: Improve variables listing and disabled case, fix resource sorting (no-changelog) (#5903) * fix: Improve variables disabled experience and fix sorting * fix: update action box margin * test: update tests for variables row and datatable * fix: Add ee controller to base controller * fix: variables.ee routes not being added * feat: add variables validation * fix: fix vue-fragment bug that breaks everything * chore: Update lock * feat: Add variables input validation and permissions (no-changelog) (#5910) * feat: add input validation * feat: handle variables view for non-instance-owner users * test: update variables tests * fix: fix data-testid pattern * feat: improve overflow styles * test: fix variables row snapshot * feat: update sorting to take newly created variables into account * fix: fix list layout overflow * fix: fix adding variables on page other than 1. fix validation * feat: add docs link * fix: fix default displayName function for resource-list-layout * feat: improve vars expressions ux, cm-tooltip * test: fix datatable test * feat: add MATCH_REGEX validation rule * fix: overhaul how datatable pagination selector works * feat: update completer description * fix: conditionally update usage syntax based on key validation * test: update datatable snapshot * fix: fix variables-row button margins * fix: fix pagination overflow * test: Fix broken test * test: Update snapshot * fix: Remove duplicate declaration * feat: add custom variables icon --------- Co-authored-by: Alex Grozav Co-authored-by: Omar Ajoue --- packages/cli/src/Db.ts | 2 + packages/cli/src/Interfaces.ts | 7 + packages/cli/src/InternalHooks.ts | 4 + packages/cli/src/License.ts | 17 +- packages/cli/src/Server.ts | 18 + .../cli/src/WorkflowExecuteAdditionalData.ts | 6 +- packages/cli/src/WorkflowHelpers.ts | 9 + packages/cli/src/constants.ts | 6 + .../cli/src/databases/entities/Variables.ts | 16 + packages/cli/src/databases/entities/index.ts | 2 + .../mysqldb/1677501636753-CreateVariables.ts | 33 ++ .../src/databases/migrations/mysqldb/index.ts | 2 + .../1677501636754-CreateVariables.ts | 32 ++ .../databases/migrations/postgresdb/index.ts | 2 + .../sqlite/1677501636752-CreateVariables.ts | 32 ++ .../src/databases/migrations/sqlite/index.ts | 2 + .../cli/src/databases/repositories/index.ts | 1 + .../repositories/variables.repository.ts | 10 + .../cli/src/environments/enviromentHelpers.ts | 26 ++ .../environments/variables.controller.ee.ts | 79 ++++ .../src/environments/variables.controller.ts | 82 ++++ .../src/environments/variables.service.ee.ts | 45 +++ .../cli/src/environments/variables.service.ts | 20 + packages/cli/src/requests.ts | 15 + .../cli/test/integration/shared/testDb.ts | 27 ++ .../cli/test/integration/shared/types.d.ts | 3 +- packages/cli/test/integration/shared/utils.ts | 4 +- .../cli/test/integration/variables.test.ts | 379 ++++++++++++++++++ .../test/unit/ActiveWorkflowRunner.test.ts | 3 + packages/core/src/NodeExecuteFunctions.ts | 1 + .../src/components/N8nDatatable/Datatable.vue | 133 +++--- .../N8nDatatable/__tests__/Datatable.spec.ts | 42 +- .../__snapshots__/Datatable.spec.ts.snap | 22 +- .../components/N8nDatatable/__tests__/data.ts | 2 +- .../src/components/N8nFormInput/FormInput.vue | 10 +- .../src/components/N8nFormInput/validators.ts | 14 + .../components/N8nPagination/Pagination.vue | 22 +- .../src/plugins/n8nComponents.ts | 4 + .../mixins.ts => types/datatable.ts} | 4 +- packages/design-system/src/types/form.ts | 2 +- packages/design-system/src/types/index.ts | 1 + packages/editor-ui/package.json | 1 + packages/editor-ui/src/Interface.ts | 10 + .../src/__tests__/server/endpoints/index.ts | 8 +- .../__tests__/server/endpoints/settings.ts | 75 ++++ .../src/__tests__/server/endpoints/user.ts | 8 + .../__tests__/server/endpoints/variable.ts | 41 ++ .../src/__tests__/server/factories/index.ts | 3 + .../__tests__/server/factories/variable.ts | 15 + .../editor-ui/src/__tests__/server/index.ts | 2 + .../src/__tests__/server/models/index.ts | 2 + .../src/__tests__/server/models/variable.ts | 5 + packages/editor-ui/src/api/environments.ee.ts | 40 ++ .../editor-ui/src/components/CodeEdit.vue | 1 + .../components/CodeNodeEditor/completer.ts | 4 + .../completions/base.completions.ts | 4 + .../completions/variables.completions.ts | 33 ++ .../editor-ui/src/components/MainSidebar.vue | 14 + .../editor-ui/src/components/VariablesRow.vue | 272 +++++++++++++ .../components/__tests__/VariablesRow.spec.ts | 98 +++++ .../__snapshots__/VariablesRow.spec.ts.snap | 78 ++++ .../components/layouts/PageViewLayoutList.vue | 23 +- .../layouts/ResourcesListLayout.vue | 159 ++++++-- packages/editor-ui/src/composables/index.ts | 8 + .../src/composables/useCopyToClipboard.ts | 5 + .../src/composables/useExternalHooks.ts | 12 + packages/editor-ui/src/composables/useI18n.ts | 5 + .../editor-ui/src/composables/useMessage.ts | 65 +++ .../editor-ui/src/composables/useTelemetry.ts | 5 + .../editor-ui/src/composables/useToast.ts | 142 +++++++ .../src/composables/useUpgradeLink.ts | 25 ++ packages/editor-ui/src/constants.ts | 3 + .../editor-ui/src/mixins/workflowHelpers.ts | 2 + packages/editor-ui/src/permissions.ts | 28 +- .../completions/datatype.completions.ts | 22 +- packages/editor-ui/src/plugins/components.ts | 65 +-- packages/editor-ui/src/plugins/i18n/index.ts | 1 + .../src/plugins/i18n/locales/en.json | 44 ++ .../editor-ui/src/plugins/icons/custom.ts | 13 + .../src/plugins/{icons.ts => icons/index.ts} | 12 +- packages/editor-ui/src/router.ts | 16 + .../src/stores/__tests__/environments.spec.ts | 99 +++++ .../src/stores/{ => __tests__}/usage.test.ts | 0 .../editor-ui/src/stores/environments.ee.ts | 65 +++ packages/editor-ui/src/stores/index.ts | 23 ++ packages/editor-ui/src/stores/ui.ts | 8 + .../src/styles/autocomplete-theme.scss | 6 + .../editor-ui/src/views/CredentialsView.vue | 2 +- packages/editor-ui/src/views/NodeView.vue | 10 +- .../editor-ui/src/views/VariablesView.vue | 338 ++++++++++++++++ .../src/views/__tests__/VariablesView.spec.ts | 49 +++ packages/nodes-base/test/nodes/Helpers.ts | 1 + packages/workflow/src/Interfaces.ts | 1 + pnpm-lock.yaml | 13 + 94 files changed, 2925 insertions(+), 200 deletions(-) create mode 100644 packages/cli/src/databases/entities/Variables.ts create mode 100644 packages/cli/src/databases/migrations/mysqldb/1677501636753-CreateVariables.ts create mode 100644 packages/cli/src/databases/migrations/postgresdb/1677501636754-CreateVariables.ts create mode 100644 packages/cli/src/databases/migrations/sqlite/1677501636752-CreateVariables.ts create mode 100644 packages/cli/src/databases/repositories/variables.repository.ts create mode 100644 packages/cli/src/environments/enviromentHelpers.ts create mode 100644 packages/cli/src/environments/variables.controller.ee.ts create mode 100644 packages/cli/src/environments/variables.controller.ts create mode 100644 packages/cli/src/environments/variables.service.ee.ts create mode 100644 packages/cli/src/environments/variables.service.ts create mode 100644 packages/cli/test/integration/variables.test.ts rename packages/design-system/src/{components/N8nDatatable/mixins.ts => types/datatable.ts} (71%) create mode 100644 packages/editor-ui/src/__tests__/server/endpoints/settings.ts create mode 100644 packages/editor-ui/src/__tests__/server/endpoints/variable.ts create mode 100644 packages/editor-ui/src/__tests__/server/factories/variable.ts create mode 100644 packages/editor-ui/src/__tests__/server/models/variable.ts create mode 100644 packages/editor-ui/src/api/environments.ee.ts create mode 100644 packages/editor-ui/src/components/CodeNodeEditor/completions/variables.completions.ts create mode 100644 packages/editor-ui/src/components/VariablesRow.vue create mode 100644 packages/editor-ui/src/components/__tests__/VariablesRow.spec.ts create mode 100644 packages/editor-ui/src/components/__tests__/__snapshots__/VariablesRow.spec.ts.snap create mode 100644 packages/editor-ui/src/composables/index.ts create mode 100644 packages/editor-ui/src/composables/useCopyToClipboard.ts create mode 100644 packages/editor-ui/src/composables/useExternalHooks.ts create mode 100644 packages/editor-ui/src/composables/useI18n.ts create mode 100644 packages/editor-ui/src/composables/useMessage.ts create mode 100644 packages/editor-ui/src/composables/useTelemetry.ts create mode 100644 packages/editor-ui/src/composables/useToast.ts create mode 100644 packages/editor-ui/src/composables/useUpgradeLink.ts create mode 100644 packages/editor-ui/src/plugins/icons/custom.ts rename packages/editor-ui/src/plugins/{icons.ts => icons/index.ts} (93%) create mode 100644 packages/editor-ui/src/stores/__tests__/environments.spec.ts rename packages/editor-ui/src/stores/{ => __tests__}/usage.test.ts (100%) create mode 100644 packages/editor-ui/src/stores/environments.ee.ts create mode 100644 packages/editor-ui/src/stores/index.ts create mode 100644 packages/editor-ui/src/views/VariablesView.vue create mode 100644 packages/editor-ui/src/views/__tests__/VariablesView.spec.ts diff --git a/packages/cli/src/Db.ts b/packages/cli/src/Db.ts index c97a95cd9e224..85cee05551553 100644 --- a/packages/cli/src/Db.ts +++ b/packages/cli/src/Db.ts @@ -34,6 +34,7 @@ import { SharedWorkflowRepository, TagRepository, UserRepository, + VariablesRepository, WebhookRepository, WorkflowRepository, WorkflowStatisticsRepository, @@ -178,6 +179,7 @@ export async function init( collections.SharedWorkflow = Container.get(SharedWorkflowRepository); collections.Tag = Container.get(TagRepository); collections.User = Container.get(UserRepository); + collections.Variables = Container.get(VariablesRepository); collections.Webhook = Container.get(WebhookRepository); collections.Workflow = Container.get(WorkflowRepository); collections.WorkflowStatistics = Container.get(WorkflowStatisticsRepository); diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index cb4ad810f7f87..092a06d133a97 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -56,6 +56,7 @@ import type { SharedWorkflowRepository, TagRepository, UserRepository, + VariablesRepository, WebhookRepository, WorkflowRepository, WorkflowStatisticsRepository, @@ -99,6 +100,7 @@ export interface IDatabaseCollections { SharedWorkflow: SharedWorkflowRepository; Tag: TagRepository; User: UserRepository; + Variables: VariablesRepository; Webhook: WebhookRepository; Workflow: WorkflowRepository; WorkflowStatistics: WorkflowStatisticsRepository; @@ -458,6 +460,7 @@ export interface IInternalHooksClass { }): Promise; onApiKeyCreated(apiKeyDeletedData: { user: User; public_api: boolean }): Promise; onApiKeyDeleted(apiKeyDeletedData: { user: User; public_api: boolean }): Promise; + onVariableCreated(createData: { variable_type: string }): Promise; } export interface IVersionNotificationSettings { @@ -538,11 +541,15 @@ export interface IN8nUISettings { saml: boolean; logStreaming: boolean; advancedExecutionFilters: boolean; + variables: boolean; }; hideUsagePage: boolean; license: { environment: 'production' | 'staging'; }; + variables: { + limit: number; + }; } export interface IPersonalizationSurveyAnswers { diff --git a/packages/cli/src/InternalHooks.ts b/packages/cli/src/InternalHooks.ts index 0092a3d990d90..6a1ab61b64715 100644 --- a/packages/cli/src/InternalHooks.ts +++ b/packages/cli/src/InternalHooks.ts @@ -981,4 +981,8 @@ export class InternalHooks implements IInternalHooksClass { async onAuditGeneratedViaCli() { return this.telemetry.track('Instance generated security audit via CLI command'); } + + async onVariableCreated(createData: { variable_type: string }): Promise { + return this.telemetry.track('User created variable', createData); + } } diff --git a/packages/cli/src/License.ts b/packages/cli/src/License.ts index 19d3e9510fdd7..e57625720c94a 100644 --- a/packages/cli/src/License.ts +++ b/packages/cli/src/License.ts @@ -4,7 +4,12 @@ import type { ILogger } from 'n8n-workflow'; import { getLogger } from './Logger'; import config from '@/config'; import * as Db from '@/Db'; -import { LICENSE_FEATURES, N8N_VERSION, SETTINGS_LICENSE_CERT_KEY } from './constants'; +import { + LICENSE_FEATURES, + LICENSE_QUOTAS, + N8N_VERSION, + SETTINGS_LICENSE_CERT_KEY, +} from './constants'; import { Service } from 'typedi'; async function loadCertStr(): Promise { @@ -119,6 +124,10 @@ export class License { return this.isFeatureEnabled(LICENSE_FEATURES.ADVANCED_EXECUTION_FILTERS); } + isVariablesEnabled() { + return this.isFeatureEnabled(LICENSE_FEATURES.VARIABLES); + } + getCurrentEntitlements() { return this.manager?.getCurrentEntitlements() ?? []; } @@ -162,7 +171,11 @@ export class License { // Helper functions for computed data getTriggerLimit(): number { - return (this.getFeatureValue('quota:activeWorkflows') ?? -1) as number; + return (this.getFeatureValue(LICENSE_QUOTAS.TRIGGER_LIMIT) ?? -1) as number; + } + + getVariablesLimit(): number { + return (this.getFeatureValue(LICENSE_QUOTAS.VARIABLES_LIMIT) ?? -1) as number; } getPlanName(): string { diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 9081651b878b7..29439a72a33d4 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -156,7 +156,9 @@ import { import { getSamlLoginLabel, isSamlLoginEnabled, isSamlLicensed } from './sso/saml/samlHelpers'; import { SamlController } from './sso/saml/routes/saml.controller.ee'; import { SamlService } from './sso/saml/saml.service.ee'; +import { variablesController } from './environments/variables.controller'; import { LdapManager } from './Ldap/LdapManager.ee'; +import { getVariablesLimit, isVariablesEnabled } from '@/environments/enviromentHelpers'; import { getCurrentAuthenticationMethod } from './sso/ssoHelpers'; const exec = promisify(callbackExec); @@ -317,11 +319,15 @@ class Server extends AbstractServer { saml: false, logStreaming: false, advancedExecutionFilters: false, + variables: false, }, hideUsagePage: config.getEnv('hideUsagePage'), license: { environment: config.getEnv('license.tenantId') === 1 ? 'production' : 'staging', }, + variables: { + limit: 0, + }, }; } @@ -347,6 +353,7 @@ class Server extends AbstractServer { ldap: isLdapEnabled(), saml: isSamlLicensed(), advancedExecutionFilters: isAdvancedExecutionFiltersEnabled(), + variables: isVariablesEnabled(), }); if (isLdapEnabled()) { @@ -363,6 +370,10 @@ class Server extends AbstractServer { }); } + if (isVariablesEnabled()) { + this.frontendSettings.variables.limit = getVariablesLimit(); + } + if (config.get('nodes.packagesMissing').length > 0) { this.frontendSettings.missingPackages = true; } @@ -540,6 +551,13 @@ class Server extends AbstractServer { } // ---------------------------------------- + // Variables + // ---------------------------------------- + + this.app.use(`/${this.restEndpoint}/variables`, variablesController); + + // ---------------------------------------- + // Returns parameter values which normally get loaded from an external API or // get generated dynamically this.app.get( diff --git a/packages/cli/src/WorkflowExecuteAdditionalData.ts b/packages/cli/src/WorkflowExecuteAdditionalData.ts index d2344c80bff0a..f958d3a49508d 100644 --- a/packages/cli/src/WorkflowExecuteAdditionalData.ts +++ b/packages/cli/src/WorkflowExecuteAdditionalData.ts @@ -1164,7 +1164,10 @@ export async function getBase( const webhookWaitingBaseUrl = urlBaseWebhook + config.getEnv('endpoints.webhookWaiting'); const webhookTestBaseUrl = urlBaseWebhook + config.getEnv('endpoints.webhookTest'); - const encryptionKey = await UserSettings.getEncryptionKey(); + const [encryptionKey, variables] = await Promise.all([ + UserSettings.getEncryptionKey(), + WorkflowHelpers.getVariables(), + ]); return { credentialsHelper: new CredentialsHelper(encryptionKey), @@ -1179,6 +1182,7 @@ export async function getBase( executionTimeoutTimestamp, userId, setExecutionStatus, + variables, }; } diff --git a/packages/cli/src/WorkflowHelpers.ts b/packages/cli/src/WorkflowHelpers.ts index e92d38603d102..4f8b121bbb041 100644 --- a/packages/cli/src/WorkflowHelpers.ts +++ b/packages/cli/src/WorkflowHelpers.ts @@ -562,3 +562,12 @@ export function validateWorkflowCredentialUsage( return newWorkflowVersion; } + +export async function getVariables(): Promise { + return Object.freeze( + (await Db.collections.Variables.find()).reduce((prev, curr) => { + prev[curr.key] = curr.value; + return prev; + }, {} as IDataObject), + ); +} diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index 82f87dc13c048..938739069aa2a 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -74,6 +74,12 @@ export enum LICENSE_FEATURES { SAML = 'feat:saml', LOG_STREAMING = 'feat:logStreaming', ADVANCED_EXECUTION_FILTERS = 'feat:advancedExecutionFilters', + VARIABLES = 'feat:variables', +} + +export enum LICENSE_QUOTAS { + TRIGGER_LIMIT = 'quota:activeWorkflows', + VARIABLES_LIMIT = 'quota:maxVariables', } export const CREDENTIAL_BLANKING_VALUE = '__n8n_BLANK_VALUE_e5362baf-c777-4d57-a609-6eaf1f9e87f6'; diff --git a/packages/cli/src/databases/entities/Variables.ts b/packages/cli/src/databases/entities/Variables.ts new file mode 100644 index 0000000000000..64eef5a9fc8f9 --- /dev/null +++ b/packages/cli/src/databases/entities/Variables.ts @@ -0,0 +1,16 @@ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity() +export class Variables { + @PrimaryGeneratedColumn() + id: number; + + @Column('text') + key: string; + + @Column('text', { default: 'string' }) + type: string; + + @Column('text') + value: string; +} diff --git a/packages/cli/src/databases/entities/index.ts b/packages/cli/src/databases/entities/index.ts index 9440a96b24e24..8e2fdd75872ca 100644 --- a/packages/cli/src/databases/entities/index.ts +++ b/packages/cli/src/databases/entities/index.ts @@ -12,6 +12,7 @@ import { SharedCredentials } from './SharedCredentials'; import { SharedWorkflow } from './SharedWorkflow'; import { TagEntity } from './TagEntity'; import { User } from './User'; +import { Variables } from './Variables'; import { WebhookEntity } from './WebhookEntity'; import { WorkflowEntity } from './WorkflowEntity'; import { WorkflowTagMapping } from './WorkflowTagMapping'; @@ -32,6 +33,7 @@ export const entities = { SharedWorkflow, TagEntity, User, + Variables, WebhookEntity, WorkflowEntity, WorkflowTagMapping, diff --git a/packages/cli/src/databases/migrations/mysqldb/1677501636753-CreateVariables.ts b/packages/cli/src/databases/migrations/mysqldb/1677501636753-CreateVariables.ts new file mode 100644 index 0000000000000..1e35fe5574518 --- /dev/null +++ b/packages/cli/src/databases/migrations/mysqldb/1677501636753-CreateVariables.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { logMigrationEnd, logMigrationStart, getTablePrefix } from '@db/utils/migrationHelpers'; +import config from '@/config'; + +export class CreateVariables1677501636753 implements MigrationInterface { + name = 'CreateVariables1677501636753'; + public async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + const tablePrefix = getTablePrefix(); + + await queryRunner.query(` + CREATE TABLE ${tablePrefix}variables ( + id int(11) auto_increment NOT NULL PRIMARY KEY, + \`key\` VARCHAR(50) NOT NULL, + \`type\` VARCHAR(50) DEFAULT 'string' NOT NULL, + value VARCHAR(255) NULL, + UNIQUE (\`key\`) + ) + ENGINE=InnoDB; + `); + + logMigrationEnd(this.name); + } + + public async down(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + const tablePrefix = getTablePrefix(); + + await queryRunner.query(`DROP TABLE ${tablePrefix}variables;`); + + logMigrationEnd(this.name); + } +} diff --git a/packages/cli/src/databases/migrations/mysqldb/index.ts b/packages/cli/src/databases/migrations/mysqldb/index.ts index bb021d495b800..6d97fbceb14cb 100644 --- a/packages/cli/src/databases/migrations/mysqldb/index.ts +++ b/packages/cli/src/databases/migrations/mysqldb/index.ts @@ -35,6 +35,7 @@ import { AddStatusToExecutions1674138566000 } from './1674138566000-AddStatusToE import { MigrateExecutionStatus1676996103000 } from './1676996103000-MigrateExecutionStatus'; import { UpdateRunningExecutionStatus1677236788851 } from './1677236788851-UpdateRunningExecutionStatus'; import { CreateExecutionMetadataTable1679416281779 } from './1679416281779-CreateExecutionMetadataTable'; +import { CreateVariables1677501636753 } from './1677501636753-CreateVariables'; export const mysqlMigrations = [ InitialMigration1588157391238, @@ -74,4 +75,5 @@ export const mysqlMigrations = [ MigrateExecutionStatus1676996103000, UpdateRunningExecutionStatus1677236788851, CreateExecutionMetadataTable1679416281779, + CreateVariables1677501636753, ]; diff --git a/packages/cli/src/databases/migrations/postgresdb/1677501636754-CreateVariables.ts b/packages/cli/src/databases/migrations/postgresdb/1677501636754-CreateVariables.ts new file mode 100644 index 0000000000000..106d844086907 --- /dev/null +++ b/packages/cli/src/databases/migrations/postgresdb/1677501636754-CreateVariables.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { logMigrationEnd, logMigrationStart, getTablePrefix } from '@db/utils/migrationHelpers'; +import config from '@/config'; + +export class CreateVariables1677501636754 implements MigrationInterface { + name = 'CreateVariables1677501636754'; + public async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + const tablePrefix = getTablePrefix(); + + await queryRunner.query(` + CREATE TABLE public.variables ( + id serial4 NOT NULL PRIMARY KEY, + "key" varchar(50) NOT NULL, + "type" varchar(50) NOT NULL DEFAULT 'string', + value varchar(255) NULL, + UNIQUE ("key") + ); + `); + + logMigrationEnd(this.name); + } + + public async down(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + const tablePrefix = getTablePrefix(); + + await queryRunner.query(`DROP TABLE ${tablePrefix}variables;`); + + logMigrationEnd(this.name); + } +} diff --git a/packages/cli/src/databases/migrations/postgresdb/index.ts b/packages/cli/src/databases/migrations/postgresdb/index.ts index 0c0c33ca38624..175ff14848473 100644 --- a/packages/cli/src/databases/migrations/postgresdb/index.ts +++ b/packages/cli/src/databases/migrations/postgresdb/index.ts @@ -33,6 +33,7 @@ import { AddStatusToExecutions1674138566000 } from './1674138566000-AddStatusToE import { MigrateExecutionStatus1676996103000 } from './1676996103000-MigrateExecutionStatus'; import { UpdateRunningExecutionStatus1677236854063 } from './1677236854063-UpdateRunningExecutionStatus'; import { CreateExecutionMetadataTable1679416281778 } from './1679416281778-CreateExecutionMetadataTable'; +import { CreateVariables1677501636754 } from './1677501636754-CreateVariables'; export const postgresMigrations = [ InitialMigration1587669153312, @@ -70,4 +71,5 @@ export const postgresMigrations = [ MigrateExecutionStatus1676996103000, UpdateRunningExecutionStatus1677236854063, CreateExecutionMetadataTable1679416281778, + CreateVariables1677501636754, ]; diff --git a/packages/cli/src/databases/migrations/sqlite/1677501636752-CreateVariables.ts b/packages/cli/src/databases/migrations/sqlite/1677501636752-CreateVariables.ts new file mode 100644 index 0000000000000..da6265defa9e1 --- /dev/null +++ b/packages/cli/src/databases/migrations/sqlite/1677501636752-CreateVariables.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { logMigrationEnd, logMigrationStart, getTablePrefix } from '@db/utils/migrationHelpers'; +import config from '@/config'; + +export class CreateVariables1677501636752 implements MigrationInterface { + name = 'CreateVariables1677501636752'; + public async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + const tablePrefix = getTablePrefix(); + + await queryRunner.query(` + CREATE TABLE ${tablePrefix}variables ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "key" TEXT NOT NULL, + "type" TEXT NOT NULL DEFAULT ('string'), + value TEXT, + UNIQUE("key") + ); + `); + + logMigrationEnd(this.name); + } + + public async down(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + const tablePrefix = getTablePrefix(); + + await queryRunner.query(`DROP TABLE ${tablePrefix}variables;`); + + logMigrationEnd(this.name); + } +} diff --git a/packages/cli/src/databases/migrations/sqlite/index.ts b/packages/cli/src/databases/migrations/sqlite/index.ts index 2d1d3e6709201..b7e324823fb86 100644 --- a/packages/cli/src/databases/migrations/sqlite/index.ts +++ b/packages/cli/src/databases/migrations/sqlite/index.ts @@ -32,6 +32,7 @@ import { AddStatusToExecutions1674138566000 } from './1674138566000-AddStatusToE import { MigrateExecutionStatus1676996103000 } from './1676996103000-MigrateExecutionStatus'; import { UpdateRunningExecutionStatus1677237073720 } from './1677237073720-UpdateRunningExecutionStatus'; import { CreateExecutionMetadataTable1679416281777 } from './1679416281777-CreateExecutionMetadataTable'; +import { CreateVariables1677501636752 } from './1677501636752-CreateVariables'; const sqliteMigrations = [ InitialMigration1588102412422, @@ -67,6 +68,7 @@ const sqliteMigrations = [ AddStatusToExecutions1674138566000, MigrateExecutionStatus1676996103000, UpdateRunningExecutionStatus1677237073720, + CreateVariables1677501636752, CreateExecutionMetadataTable1679416281777, ]; diff --git a/packages/cli/src/databases/repositories/index.ts b/packages/cli/src/databases/repositories/index.ts index e1e7cb02ed17f..04a11369c64f4 100644 --- a/packages/cli/src/databases/repositories/index.ts +++ b/packages/cli/src/databases/repositories/index.ts @@ -12,6 +12,7 @@ export { SharedCredentialsRepository } from './sharedCredentials.repository'; export { SharedWorkflowRepository } from './sharedWorkflow.repository'; export { TagRepository } from './tag.repository'; export { UserRepository } from './user.repository'; +export { VariablesRepository } from './variables.repository'; export { WebhookRepository } from './webhook.repository'; export { WorkflowRepository } from './workflow.repository'; export { WorkflowStatisticsRepository } from './workflowStatistics.repository'; diff --git a/packages/cli/src/databases/repositories/variables.repository.ts b/packages/cli/src/databases/repositories/variables.repository.ts new file mode 100644 index 0000000000000..d787a8b98431e --- /dev/null +++ b/packages/cli/src/databases/repositories/variables.repository.ts @@ -0,0 +1,10 @@ +import { Service } from 'typedi'; +import { DataSource, Repository } from 'typeorm'; +import { Variables } from '../entities/Variables'; + +@Service() +export class VariablesRepository extends Repository { + constructor(dataSource: DataSource) { + super(Variables, dataSource.manager); + } +} diff --git a/packages/cli/src/environments/enviromentHelpers.ts b/packages/cli/src/environments/enviromentHelpers.ts new file mode 100644 index 0000000000000..d7cc12249264b --- /dev/null +++ b/packages/cli/src/environments/enviromentHelpers.ts @@ -0,0 +1,26 @@ +import { License } from '@/License'; +import Container from 'typedi'; + +export function isVariablesEnabled(): boolean { + const license = Container.get(License); + return license.isVariablesEnabled(); +} + +export function canCreateNewVariable(variableCount: number): boolean { + if (!isVariablesEnabled()) { + return false; + } + const license = Container.get(License); + // This defaults to -1 which is what we want if we've enabled + // variables via the config + const limit = license.getVariablesLimit(); + if (limit === -1) { + return true; + } + return limit > variableCount; +} + +export function getVariablesLimit(): number { + const license = Container.get(License); + return license.getVariablesLimit(); +} diff --git a/packages/cli/src/environments/variables.controller.ee.ts b/packages/cli/src/environments/variables.controller.ee.ts new file mode 100644 index 0000000000000..3be05f32855d0 --- /dev/null +++ b/packages/cli/src/environments/variables.controller.ee.ts @@ -0,0 +1,79 @@ +import express from 'express'; +import { LoggerProxy } from 'n8n-workflow'; + +import * as ResponseHelper from '@/ResponseHelper'; +import type { VariablesRequest } from '@/requests'; +import { + VariablesLicenseError, + EEVariablesService, + VariablesValidationError, +} from './variables.service.ee'; +import { isVariablesEnabled } from './enviromentHelpers'; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export const EEVariablesController = express.Router(); + +/** + * Initialize Logger if needed + */ +EEVariablesController.use((req, res, next) => { + if (!isVariablesEnabled()) { + next('router'); + return; + } + + next(); +}); + +EEVariablesController.post( + '/', + ResponseHelper.send(async (req: VariablesRequest.Create) => { + if (req.user.globalRole.name !== 'owner') { + LoggerProxy.info('Attempt to update a variable blocked due to lack of permissions', { + userId: req.user.id, + }); + throw new ResponseHelper.AuthError('Unauthorized'); + } + const variable = req.body; + delete variable.id; + try { + return await EEVariablesService.create(variable); + } catch (error) { + if (error instanceof VariablesLicenseError) { + throw new ResponseHelper.BadRequestError(error.message); + } else if (error instanceof VariablesValidationError) { + throw new ResponseHelper.BadRequestError(error.message); + } + throw error; + } + }), +); + +EEVariablesController.patch( + '/:id(\\d+)', + ResponseHelper.send(async (req: VariablesRequest.Update) => { + const id = parseInt(req.params.id); + if (isNaN(id)) { + throw new ResponseHelper.BadRequestError('Invalid variable id ' + req.params.id); + } + if (req.user.globalRole.name !== 'owner') { + LoggerProxy.info('Attempt to update a variable blocked due to lack of permissions', { + id, + userId: req.user.id, + }); + throw new ResponseHelper.AuthError('Unauthorized'); + } + const variable = req.body; + delete variable.id; + try { + return await EEVariablesService.update(id, variable); + } catch (error) { + if (error instanceof VariablesLicenseError) { + throw new ResponseHelper.BadRequestError(error.message); + } else if (error instanceof VariablesValidationError) { + throw new ResponseHelper.BadRequestError(error.message); + } + throw error; + } + }), +); diff --git a/packages/cli/src/environments/variables.controller.ts b/packages/cli/src/environments/variables.controller.ts new file mode 100644 index 0000000000000..931df7784d8f1 --- /dev/null +++ b/packages/cli/src/environments/variables.controller.ts @@ -0,0 +1,82 @@ +import express from 'express'; +import { LoggerProxy } from 'n8n-workflow'; + +import { getLogger } from '@/Logger'; +import * as ResponseHelper from '@/ResponseHelper'; +import type { VariablesRequest } from '@/requests'; +import { VariablesService } from './variables.service'; +import { EEVariablesController } from './variables.controller.ee'; + +export const variablesController = express.Router(); + +variablesController.use('/', EEVariablesController); + +/** + * Initialize Logger if needed + */ +variablesController.use((req, res, next) => { + try { + LoggerProxy.getInstance(); + } catch (error) { + LoggerProxy.init(getLogger()); + } + next(); +}); + +variablesController.use(EEVariablesController); + +variablesController.get( + '/', + ResponseHelper.send(async () => { + return VariablesService.getAll(); + }), +); + +variablesController.post( + '/', + ResponseHelper.send(async () => { + throw new ResponseHelper.BadRequestError('No variables license found'); + }), +); + +variablesController.get( + '/:id(\\d+)', + ResponseHelper.send(async (req: VariablesRequest.Get) => { + const id = parseInt(req.params.id); + if (isNaN(id)) { + throw new ResponseHelper.BadRequestError('Invalid variable id ' + req.params.id); + } + const variable = await VariablesService.get(id); + if (variable === null) { + throw new ResponseHelper.NotFoundError(`Variable with id ${req.params.id} not found`); + } + return variable; + }), +); + +variablesController.patch( + '/:id(\\d+)', + ResponseHelper.send(async () => { + throw new ResponseHelper.BadRequestError('No variables license found'); + }), +); + +variablesController.delete( + '/:id(\\d+)', + ResponseHelper.send(async (req: VariablesRequest.Delete) => { + const id = parseInt(req.params.id); + if (isNaN(id)) { + throw new ResponseHelper.BadRequestError('Invalid variable id ' + req.params.id); + } + if (req.user.globalRole.name !== 'owner') { + LoggerProxy.info('Attempt to delete a variable blocked due to lack of permissions', { + id, + userId: req.user.id, + }); + throw new ResponseHelper.AuthError('Unauthorized'); + } + await VariablesService.delete(id); + + return true; + }), +); diff --git a/packages/cli/src/environments/variables.service.ee.ts b/packages/cli/src/environments/variables.service.ee.ts new file mode 100644 index 0000000000000..b5c48dcef08d7 --- /dev/null +++ b/packages/cli/src/environments/variables.service.ee.ts @@ -0,0 +1,45 @@ +import type { Variables } from '@/databases/entities/Variables'; +import { collections } from '@/Db'; +import { InternalHooks } from '@/InternalHooks'; +import Container from 'typedi'; +import { canCreateNewVariable } from './enviromentHelpers'; +import { VariablesService } from './variables.service'; + +export class VariablesLicenseError extends Error {} +export class VariablesValidationError extends Error {} + +export class EEVariablesService extends VariablesService { + static async getCount(): Promise { + return collections.Variables.count(); + } + + static validateVariable(variable: Omit): void { + if (variable.key.length > 50) { + throw new VariablesValidationError('key cannot be longer than 50 characters'); + } + if (variable.key.replace(/[A-Za-z0-9_]/g, '').length !== 0) { + throw new VariablesValidationError('key can only contain characters A-Za-z0-9_'); + } + if (variable.value.length > 255) { + throw new VariablesValidationError('value cannot be longer than 255 characters'); + } + } + + static async create(variable: Omit): Promise { + if (!canCreateNewVariable(await this.getCount())) { + throw new VariablesLicenseError('Variables limit reached'); + } + this.validateVariable(variable); + + void Container.get(InternalHooks).onVariableCreated({ variable_type: variable.type }); + return collections.Variables.save(variable); + } + + static async update(id: number, variable: Omit): Promise { + this.validateVariable(variable); + + await collections.Variables.update(id, variable); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return (await this.get(id))!; + } +} diff --git a/packages/cli/src/environments/variables.service.ts b/packages/cli/src/environments/variables.service.ts new file mode 100644 index 0000000000000..646f9368f2382 --- /dev/null +++ b/packages/cli/src/environments/variables.service.ts @@ -0,0 +1,20 @@ +import type { Variables } from '@/databases/entities/Variables'; +import { collections } from '@/Db'; + +export class VariablesService { + static async getAll(): Promise { + return collections.Variables.find(); + } + + static async getCount(): Promise { + return collections.Variables.count(); + } + + static async get(id: number): Promise { + return collections.Variables.findOne({ where: { id } }); + } + + static async delete(id: number): Promise { + await collections.Variables.delete(id); + } +} diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 2c33800ec571f..af1fdae591b43 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -16,6 +16,7 @@ import type { PublicUser, IExecutionDeleteFilter, IWorkflowDb } from '@/Interfac import type { Role } from '@db/entities/Role'; import type { User } from '@db/entities/User'; import type { UserManagementMailer } from '@/UserManagement/email'; +import type { Variables } from '@db/entities/Variables'; export class UserUpdatePayload implements Pick { @IsEmail() @@ -386,3 +387,17 @@ export type BinaryDataRequest = AuthenticatedRequest< mimeType?: string; } >; + +// ---------------------------------- +// /variables +// ---------------------------------- +// +export declare namespace VariablesRequest { + type CreateUpdatePayload = Omit & { id?: unknown }; + + type GetAll = AuthenticatedRequest; + type Get = AuthenticatedRequest<{ id: string }, {}, {}, {}>; + type Create = AuthenticatedRequest<{}, {}, CreateUpdatePayload, {}>; + type Update = AuthenticatedRequest<{ id: string }, {}, CreateUpdatePayload, {}>; + type Delete = Get; +} diff --git a/packages/cli/test/integration/shared/testDb.ts b/packages/cli/test/integration/shared/testDb.ts index 2d38a84ef3975..95e7303010622 100644 --- a/packages/cli/test/integration/shared/testDb.ts +++ b/packages/cli/test/integration/shared/testDb.ts @@ -489,6 +489,33 @@ export async function getWorkflowSharing(workflow: WorkflowEntity) { }); } +// ---------------------------------- +// variables +// ---------------------------------- + +export async function createVariable(key: string, value: string) { + return Db.collections.Variables.save({ + key, + value, + }); +} + +export async function getVariableByKey(key: string) { + return Db.collections.Variables.findOne({ + where: { + key, + }, + }); +} + +export async function getVariableById(id: number) { + return Db.collections.Variables.findOne({ + where: { + id, + }, + }); +} + // ---------------------------------- // connection options // ---------------------------------- diff --git a/packages/cli/test/integration/shared/types.d.ts b/packages/cli/test/integration/shared/types.d.ts index 97bb13843ddbd..3fa94aec536db 100644 --- a/packages/cli/test/integration/shared/types.d.ts +++ b/packages/cli/test/integration/shared/types.d.ts @@ -24,7 +24,8 @@ type EndpointGroup = | 'ldap' | 'saml' | 'eventBus' - | 'license'; + | 'license' + | 'variables'; export type CredentialPayload = { name: string; diff --git a/packages/cli/test/integration/shared/utils.ts b/packages/cli/test/integration/shared/utils.ts index 619d03f9b7697..a2499b8c8d60c 100644 --- a/packages/cli/test/integration/shared/utils.ts +++ b/packages/cli/test/integration/shared/utils.ts @@ -73,6 +73,7 @@ import { v4 as uuid } from 'uuid'; import { InternalHooks } from '@/InternalHooks'; import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; import { PostHogClient } from '@/posthog'; +import { variablesController } from '@/environments/variables.controller'; import { LdapManager } from '@/Ldap/LdapManager.ee'; import { handleLdapInit } from '@/Ldap/helpers'; import { Push } from '@/push'; @@ -151,6 +152,7 @@ export async function initTestServer({ credentials: { controller: credentialsController, path: 'credentials' }, workflows: { controller: workflowsController, path: 'workflows' }, license: { controller: licenseController, path: 'license' }, + variables: { controller: variablesController, path: 'variables' }, }; if (enablePublicAPI) { @@ -268,7 +270,7 @@ const classifyEndpointGroups = (endpointGroups: EndpointGroup[]) => { const routerEndpoints: EndpointGroup[] = []; const functionEndpoints: EndpointGroup[] = []; - const ROUTER_GROUP = ['credentials', 'workflows', 'publicApi', 'license']; + const ROUTER_GROUP = ['credentials', 'workflows', 'publicApi', 'license', 'variables']; endpointGroups.forEach((group) => (ROUTER_GROUP.includes(group) ? routerEndpoints : functionEndpoints).push(group), diff --git a/packages/cli/test/integration/variables.test.ts b/packages/cli/test/integration/variables.test.ts new file mode 100644 index 0000000000000..46e0814622555 --- /dev/null +++ b/packages/cli/test/integration/variables.test.ts @@ -0,0 +1,379 @@ +import type { Application } from 'express'; + +import type { User } from '@/databases/entities/User'; +import * as testDb from './shared/testDb'; +import * as utils from './shared/utils'; + +import type { AuthAgent } from './shared/types'; +import type { ClassLike, MockedClass } from 'jest-mock'; +import { License } from '@/License'; + +// mock that credentialsSharing is not enabled +let app: Application; +let ownerUser: User; +let memberUser: User; +let authAgent: AuthAgent; +let variablesSpy: jest.SpyInstance; +let licenseLike = { + isVariablesEnabled: jest.fn().mockReturnValue(true), + getVariablesLimit: jest.fn().mockReturnValue(-1), +}; + +beforeAll(async () => { + app = await utils.initTestServer({ endpointGroups: ['variables'] }); + + utils.initConfigFile(); + utils.mockInstance(License, licenseLike); + + ownerUser = await testDb.createOwner(); + memberUser = await testDb.createUser(); + + authAgent = utils.createAuthAgent(app); +}); + +beforeEach(async () => { + await testDb.truncate(['Variables']); + licenseLike.isVariablesEnabled.mockReturnValue(true); + licenseLike.getVariablesLimit.mockReturnValue(-1); +}); + +afterAll(async () => { + await testDb.terminate(); +}); + +// ---------------------------------------- +// GET /variables - fetch all variables +// ---------------------------------------- + +test('GET /variables should return all variables for an owner', async () => { + await Promise.all([ + testDb.createVariable('test1', 'value1'), + testDb.createVariable('test2', 'value2'), + ]); + + const response = await authAgent(ownerUser).get('/variables'); + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(2); +}); + +test('GET /variables should return all variables for a member', async () => { + await Promise.all([ + testDb.createVariable('test1', 'value1'), + testDb.createVariable('test2', 'value2'), + ]); + + const response = await authAgent(memberUser).get('/variables'); + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(2); +}); + +// ---------------------------------------- +// GET /variables/:id - get a single variable +// ---------------------------------------- + +test('GET /variables/:id should return a single variable for an owner', async () => { + const [var1, var2] = await Promise.all([ + testDb.createVariable('test1', 'value1'), + testDb.createVariable('test2', 'value2'), + ]); + + const response1 = await authAgent(ownerUser).get(`/variables/${var1.id}`); + expect(response1.statusCode).toBe(200); + expect(response1.body.data.key).toBe('test1'); + + const response2 = await authAgent(ownerUser).get(`/variables/${var2.id}`); + expect(response2.statusCode).toBe(200); + expect(response2.body.data.key).toBe('test2'); +}); + +test('GET /variables/:id should return a single variable for a member', async () => { + const [var1, var2] = await Promise.all([ + testDb.createVariable('test1', 'value1'), + testDb.createVariable('test2', 'value2'), + ]); + + const response1 = await authAgent(memberUser).get(`/variables/${var1.id}`); + expect(response1.statusCode).toBe(200); + expect(response1.body.data.key).toBe('test1'); + + const response2 = await authAgent(memberUser).get(`/variables/${var2.id}`); + expect(response2.statusCode).toBe(200); + expect(response2.body.data.key).toBe('test2'); +}); + +// ---------------------------------------- +// POST /variables - create a new variable +// ---------------------------------------- + +test('POST /variables should create a new credential and return it for an owner', async () => { + const toCreate = { + key: 'create1', + value: 'createvalue1', + }; + const response = await authAgent(ownerUser).post('/variables').send(toCreate); + expect(response.statusCode).toBe(200); + expect(response.body.data.key).toBe(toCreate.key); + expect(response.body.data.value).toBe(toCreate.value); + + const [byId, byKey] = await Promise.all([ + testDb.getVariableById(response.body.data.id), + testDb.getVariableByKey(toCreate.key), + ]); + + expect(byId).not.toBeNull(); + expect(byId!.key).toBe(toCreate.key); + expect(byId!.value).toBe(toCreate.value); + + expect(byKey).not.toBeNull(); + expect(byKey!.id).toBe(response.body.data.id); + expect(byKey!.value).toBe(toCreate.value); +}); + +test('POST /variables should not create a new credential and return it for a member', async () => { + const toCreate = { + key: 'create1', + value: 'createvalue1', + }; + const response = await authAgent(memberUser).post('/variables').send(toCreate); + expect(response.statusCode).toBe(401); + expect(response.body.data?.key).not.toBe(toCreate.key); + expect(response.body.data?.value).not.toBe(toCreate.value); + + const byKey = await testDb.getVariableByKey(toCreate.key); + expect(byKey).toBeNull(); +}); + +test("POST /variables should not create a new credential and return it if the instance doesn't have a license", async () => { + licenseLike.isVariablesEnabled.mockReturnValue(false); + const toCreate = { + key: 'create1', + value: 'createvalue1', + }; + const response = await authAgent(ownerUser).post('/variables').send(toCreate); + expect(response.statusCode).toBe(400); + expect(response.body.data?.key).not.toBe(toCreate.key); + expect(response.body.data?.value).not.toBe(toCreate.value); + + const byKey = await testDb.getVariableByKey(toCreate.key); + expect(byKey).toBeNull(); +}); + +test('POST /variables should fail to create a new credential and if one with the same key exists', async () => { + const toCreate = { + key: 'create1', + value: 'createvalue1', + }; + await testDb.createVariable(toCreate.key, toCreate.value); + const response = await authAgent(ownerUser).post('/variables').send(toCreate); + expect(response.statusCode).toBe(500); + expect(response.body.data?.key).not.toBe(toCreate.key); + expect(response.body.data?.value).not.toBe(toCreate.value); +}); + +test('POST /variables should not fail if variable limit not reached', async () => { + licenseLike.getVariablesLimit.mockReturnValue(5); + let i = 1; + let toCreate = { + key: `create${i}`, + value: `createvalue${i}`, + }; + while (i < 3) { + await testDb.createVariable(toCreate.key, toCreate.value); + i++; + toCreate = { + key: `create${i}`, + value: `createvalue${i}`, + }; + } + const response = await authAgent(ownerUser).post('/variables').send(toCreate); + expect(response.statusCode).toBe(200); + expect(response.body.data?.key).toBe(toCreate.key); + expect(response.body.data?.value).toBe(toCreate.value); +}); + +test('POST /variables should fail if variable limit reached', async () => { + licenseLike.getVariablesLimit.mockReturnValue(5); + let i = 1; + let toCreate = { + key: `create${i}`, + value: `createvalue${i}`, + }; + while (i < 6) { + await testDb.createVariable(toCreate.key, toCreate.value); + i++; + toCreate = { + key: `create${i}`, + value: `createvalue${i}`, + }; + } + const response = await authAgent(ownerUser).post('/variables').send(toCreate); + expect(response.statusCode).toBe(400); + expect(response.body.data?.key).not.toBe(toCreate.key); + expect(response.body.data?.value).not.toBe(toCreate.value); +}); + +test('POST /variables should fail if key too long', async () => { + const toCreate = { + // 51 'a's + key: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + value: 'value', + }; + const response = await authAgent(ownerUser).post('/variables').send(toCreate); + expect(response.statusCode).toBe(400); + expect(response.body.data?.key).not.toBe(toCreate.key); + expect(response.body.data?.value).not.toBe(toCreate.value); +}); + +test('POST /variables should fail if value too long', async () => { + const toCreate = { + key: 'key', + // 256 'a's + value: + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }; + const response = await authAgent(ownerUser).post('/variables').send(toCreate); + expect(response.statusCode).toBe(400); + expect(response.body.data?.key).not.toBe(toCreate.key); + expect(response.body.data?.value).not.toBe(toCreate.value); +}); + +test("POST /variables should fail if key contain's prohibited characters", async () => { + const toCreate = { + // 51 'a's + key: 'te$t', + value: 'value', + }; + const response = await authAgent(ownerUser).post('/variables').send(toCreate); + expect(response.statusCode).toBe(400); + expect(response.body.data?.key).not.toBe(toCreate.key); + expect(response.body.data?.value).not.toBe(toCreate.value); +}); + +// ---------------------------------------- +// PATCH /variables/:id - change a variable +// ---------------------------------------- + +test('PATCH /variables/:id should modify existing credential if use is an owner', async () => { + const variable = await testDb.createVariable('test1', 'value1'); + const toModify = { + key: 'create1', + value: 'createvalue1', + }; + const response = await authAgent(ownerUser).patch(`/variables/${variable.id}`).send(toModify); + expect(response.statusCode).toBe(200); + expect(response.body.data.key).toBe(toModify.key); + expect(response.body.data.value).toBe(toModify.value); + + const [byId, byKey] = await Promise.all([ + testDb.getVariableById(response.body.data.id), + testDb.getVariableByKey(toModify.key), + ]); + + expect(byId).not.toBeNull(); + expect(byId!.key).toBe(toModify.key); + expect(byId!.value).toBe(toModify.value); + + expect(byKey).not.toBeNull(); + expect(byKey!.id).toBe(response.body.data.id); + expect(byKey!.value).toBe(toModify.value); +}); + +test('PATCH /variables/:id should modify existing credential if use is an owner', async () => { + const variable = await testDb.createVariable('test1', 'value1'); + const toModify = { + key: 'create1', + value: 'createvalue1', + }; + const response = await authAgent(ownerUser).patch(`/variables/${variable.id}`).send(toModify); + expect(response.statusCode).toBe(200); + expect(response.body.data.key).toBe(toModify.key); + expect(response.body.data.value).toBe(toModify.value); + + const [byId, byKey] = await Promise.all([ + testDb.getVariableById(response.body.data.id), + testDb.getVariableByKey(toModify.key), + ]); + + expect(byId).not.toBeNull(); + expect(byId!.key).toBe(toModify.key); + expect(byId!.value).toBe(toModify.value); + + expect(byKey).not.toBeNull(); + expect(byKey!.id).toBe(response.body.data.id); + expect(byKey!.value).toBe(toModify.value); +}); + +test('PATCH /variables/:id should not modify existing credential if use is a member', async () => { + const variable = await testDb.createVariable('test1', 'value1'); + const toModify = { + key: 'create1', + value: 'createvalue1', + }; + const response = await authAgent(memberUser).patch(`/variables/${variable.id}`).send(toModify); + expect(response.statusCode).toBe(401); + expect(response.body.data?.key).not.toBe(toModify.key); + expect(response.body.data?.value).not.toBe(toModify.value); + + const byId = await testDb.getVariableById(variable.id); + expect(byId).not.toBeNull(); + expect(byId!.key).not.toBe(toModify.key); + expect(byId!.value).not.toBe(toModify.value); +}); + +test('PATCH /variables/:id should not modify existing credential if one with the same key exists', async () => { + const toModify = { + key: 'create1', + value: 'createvalue1', + }; + const [var1, var2] = await Promise.all([ + testDb.createVariable('test1', 'value1'), + testDb.createVariable(toModify.key, toModify.value), + ]); + const response = await authAgent(ownerUser).patch(`/variables/${var1.id}`).send(toModify); + expect(response.statusCode).toBe(500); + expect(response.body.data?.key).not.toBe(toModify.key); + expect(response.body.data?.value).not.toBe(toModify.value); + + const byId = await testDb.getVariableById(var1.id); + expect(byId).not.toBeNull(); + expect(byId!.key).toBe(var1.key); + expect(byId!.value).toBe(var1.value); +}); + +// ---------------------------------------- +// DELETE /variables/:id - change a variable +// ---------------------------------------- + +test('DELETE /variables/:id should delete a single credential for an owner', async () => { + const [var1, var2, var3] = await Promise.all([ + testDb.createVariable('test1', 'value1'), + testDb.createVariable('test2', 'value2'), + testDb.createVariable('test3', 'value3'), + ]); + + const delResponse = await authAgent(ownerUser).delete(`/variables/${var1.id}`); + expect(delResponse.statusCode).toBe(200); + + const byId = await testDb.getVariableById(var1.id); + expect(byId).toBeNull(); + + const getResponse = await authAgent(ownerUser).get('/variables'); + expect(getResponse.body.data.length).toBe(2); +}); + +test('DELETE /variables/:id should not delete a single credential for a member', async () => { + const [var1, var2, var3] = await Promise.all([ + testDb.createVariable('test1', 'value1'), + testDb.createVariable('test2', 'value2'), + testDb.createVariable('test3', 'value3'), + ]); + + const delResponse = await authAgent(memberUser).delete(`/variables/${var1.id}`); + expect(delResponse.statusCode).toBe(401); + + const byId = await testDb.getVariableById(var1.id); + expect(byId).not.toBeNull(); + + const getResponse = await authAgent(memberUser).get('/variables'); + expect(getResponse.body.data.length).toBe(3); +}); diff --git a/packages/cli/test/unit/ActiveWorkflowRunner.test.ts b/packages/cli/test/unit/ActiveWorkflowRunner.test.ts index bb4927d1762b1..efe6fe22960c8 100644 --- a/packages/cli/test/unit/ActiveWorkflowRunner.test.ts +++ b/packages/cli/test/unit/ActiveWorkflowRunner.test.ts @@ -121,6 +121,9 @@ jest.mock('@/Db', () => { clear: jest.fn(), delete: jest.fn(), }, + Variables: { + find: jest.fn(() => []), + }, }, }; }); diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 053a0423a3b05..f9a32fe78f25c 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -1686,6 +1686,7 @@ export function getAdditionalKeys( } : undefined, }, + $vars: additionalData.variables, // deprecated $executionId: executionId, diff --git a/packages/design-system/src/components/N8nDatatable/Datatable.vue b/packages/design-system/src/components/N8nDatatable/Datatable.vue index 24523b783aab8..87c8ddcd33759 100644 --- a/packages/design-system/src/components/N8nDatatable/Datatable.vue +++ b/packages/design-system/src/components/N8nDatatable/Datatable.vue @@ -1,10 +1,6 @@ diff --git a/packages/design-system/src/plugins/n8nComponents.ts b/packages/design-system/src/plugins/n8nComponents.ts index 65b8f0a6c9a7b..6c182b53f2cba 100644 --- a/packages/design-system/src/plugins/n8nComponents.ts +++ b/packages/design-system/src/plugins/n8nComponents.ts @@ -10,8 +10,10 @@ import N8nButton from '../components/N8nButton'; import { N8nElButton } from '../components/N8nButton/overrides'; import N8nCallout from '../components/N8nCallout'; import N8nCard from '../components/N8nCard'; +import N8nDatatable from '../components/N8nDatatable'; import N8nFormBox from '../components/N8nFormBox'; import N8nFormInputs from '../components/N8nFormInputs'; +import N8nFormInput from '../components/N8nFormInput'; import N8nHeading from '../components/N8nHeading'; import N8nIcon from '../components/N8nIcon'; import N8nIconButton from '../components/N8nIconButton'; @@ -61,8 +63,10 @@ const n8nComponentsPlugin: PluginObject<{}> = { app.component('el-button', N8nElButton); app.component('n8n-callout', N8nCallout); app.component('n8n-card', N8nCard); + app.component('n8n-datatable', N8nDatatable); app.component('n8n-form-box', N8nFormBox); app.component('n8n-form-inputs', N8nFormInputs); + app.component('n8n-form-input', N8nFormInput); app.component('n8n-icon', N8nIcon); app.component('n8n-icon-button', N8nIconButton); app.component('n8n-info-tip', N8nInfoTip); diff --git a/packages/design-system/src/components/N8nDatatable/mixins.ts b/packages/design-system/src/types/datatable.ts similarity index 71% rename from packages/design-system/src/components/N8nDatatable/mixins.ts rename to packages/design-system/src/types/datatable.ts index 907b86c98c9c8..aa568483424cd 100644 --- a/packages/design-system/src/components/N8nDatatable/mixins.ts +++ b/packages/design-system/src/types/datatable.ts @@ -12,5 +12,7 @@ export interface DatatableColumn { id: string | number; path: string; label: string; - render: (row: DatatableRow) => (() => VNode | VNode[]) | DatatableRowDataType; + classes?: string[]; + width?: string; + render?: (row: DatatableRow) => (() => VNode | VNode[]) | DatatableRowDataType; } diff --git a/packages/design-system/src/types/form.ts b/packages/design-system/src/types/form.ts index c17389496bede..40012fecfa7fb 100644 --- a/packages/design-system/src/types/form.ts +++ b/packages/design-system/src/types/form.ts @@ -11,7 +11,7 @@ export type IValidator = { validate: ( value: Validatable, config: T, - ) => false | { messageKey: string; options?: unknown } | null; + ) => false | { messageKey: string; message?: string; options?: unknown } | null; }; export type FormState = { diff --git a/packages/design-system/src/types/index.ts b/packages/design-system/src/types/index.ts index 90c6bebafa173..9fa2387dc47b7 100644 --- a/packages/design-system/src/types/index.ts +++ b/packages/design-system/src/types/index.ts @@ -1,4 +1,5 @@ export * from './button'; +export * from './datatable'; export * from './form'; export * from './menu'; export * from './router'; diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 4ff8dd40930a4..4633fc6d877f3 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -47,6 +47,7 @@ "canvas-confetti": "^1.6.0", "codemirror-lang-html-n8n": "^1.0.0", "codemirror-lang-n8n-expression": "^0.2.0", + "copy-to-clipboard": "^3.3.3", "dateformat": "^3.0.3", "esprima-next": "5.8.4", "fast-json-stable-stringify": "^2.1.0", diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 1cba1e76eac93..00c80ca47fa8f 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -1470,6 +1470,16 @@ export type NodeAuthenticationOption = { displayOptions?: IDisplayOptions; }; +export interface EnvironmentVariable { + id: number; + key: string; + value: string; +} + +export interface TemporaryEnvironmentVariable extends Omit { + id: string; +} + export type ExecutionFilterMetadata = { key: string; value: string; diff --git a/packages/editor-ui/src/__tests__/server/endpoints/index.ts b/packages/editor-ui/src/__tests__/server/endpoints/index.ts index 0b82fa233b53c..f98c9c3823292 100644 --- a/packages/editor-ui/src/__tests__/server/endpoints/index.ts +++ b/packages/editor-ui/src/__tests__/server/endpoints/index.ts @@ -1,12 +1,16 @@ +import { Server } from 'miragejs'; import { routesForUsers } from './user'; import { routesForCredentials } from './credential'; -import { Server } from 'miragejs'; -import { routesForCredentialTypes } from '@/__tests__/server/endpoints/credentialType'; +import { routesForCredentialTypes } from './credentialType'; +import { routesForVariables } from './variable'; +import { routesForSettings } from './settings'; const endpoints: Array<(server: Server) => void> = [ routesForCredentials, routesForCredentialTypes, routesForUsers, + routesForVariables, + routesForSettings, ]; export { endpoints }; diff --git a/packages/editor-ui/src/__tests__/server/endpoints/settings.ts b/packages/editor-ui/src/__tests__/server/endpoints/settings.ts new file mode 100644 index 0000000000000..374e548d8841c --- /dev/null +++ b/packages/editor-ui/src/__tests__/server/endpoints/settings.ts @@ -0,0 +1,75 @@ +import { Response, Server } from 'miragejs'; +import { AppSchema } from '../types'; +import { IN8nUISettings, ISettingsState } from '@/Interface'; + +const defaultSettings: IN8nUISettings = { + allowedModules: {}, + communityNodesEnabled: false, + defaultLocale: '', + endpointWebhook: '', + endpointWebhookTest: '', + enterprise: { + variables: true, + }, + executionMode: '', + executionTimeout: 0, + hideUsagePage: false, + hiringBannerEnabled: false, + instanceId: '', + isNpmAvailable: false, + license: { environment: 'development' }, + logLevel: 'info', + maxExecutionTimeout: 0, + oauthCallbackUrls: { oauth1: '', oauth2: '' }, + onboardingCallPromptEnabled: false, + personalizationSurveyEnabled: false, + posthog: { + apiHost: '', + apiKey: '', + autocapture: false, + debug: false, + disableSessionRecording: false, + enabled: false, + }, + publicApi: { enabled: false, latestVersion: 0, path: '', swaggerUi: { enabled: false } }, + pushBackend: 'websocket', + saveDataErrorExecution: '', + saveDataSuccessExecution: '', + saveManualExecutions: false, + sso: { + ldap: { loginEnabled: false, loginLabel: '' }, + saml: { loginEnabled: false, loginLabel: '' }, + }, + telemetry: { + enabled: false, + }, + templates: { enabled: false, host: '' }, + timezone: '', + urlBaseEditor: '', + urlBaseWebhook: '', + userManagement: { + enabled: true, + showSetupOnFirstLoad: true, + smtpSetup: true, + }, + versionCli: '', + versionNotifications: { + enabled: true, + endpoint: '', + infoUrl: '', + }, + workflowCallerPolicyDefaultOption: 'any', + workflowTagsDisabled: false, +}; + +export function routesForSettings(server: Server) { + server.get('/rest/settings', (schema: AppSchema) => { + return new Response( + 200, + {}, + { + data: defaultSettings, + }, + ); + }); +} diff --git a/packages/editor-ui/src/__tests__/server/endpoints/user.ts b/packages/editor-ui/src/__tests__/server/endpoints/user.ts index 7822231bac02d..0400b47b96ba7 100644 --- a/packages/editor-ui/src/__tests__/server/endpoints/user.ts +++ b/packages/editor-ui/src/__tests__/server/endpoints/user.ts @@ -7,4 +7,12 @@ export function routesForUsers(server: Server) { return new Response(200, {}, { data }); }); + + server.get('/rest/login', (schema: AppSchema) => { + const model = schema.findBy('user', { + isDefaultUser: true, + }); + + return new Response(200, {}, { data: model?.attrs }); + }); } diff --git a/packages/editor-ui/src/__tests__/server/endpoints/variable.ts b/packages/editor-ui/src/__tests__/server/endpoints/variable.ts new file mode 100644 index 0000000000000..bbe3b87eb1eb9 --- /dev/null +++ b/packages/editor-ui/src/__tests__/server/endpoints/variable.ts @@ -0,0 +1,41 @@ +import { Request, Response, Server } from 'miragejs'; +import { AppSchema } from '../types'; +import { jsonParse } from 'n8n-workflow'; +import { EnvironmentVariable } from '@/Interface'; + +export function routesForVariables(server: Server) { + server.get('/rest/variables', (schema: AppSchema) => { + const { models: data } = schema.all('variable'); + + return new Response(200, {}, { data }); + }); + + server.post('/rest/variables', (schema: AppSchema, request: Request) => { + const data = schema.create('variable', jsonParse(request.requestBody)); + + return new Response(200, {}, { data }); + }); + + server.patch('/rest/variables/:id', (schema: AppSchema, request: Request) => { + const data: EnvironmentVariable = jsonParse(request.requestBody); + const id = request.params.id; + + const model = schema.find('variable', id); + if (model) { + model.update(data); + } + + return new Response(200, {}, { data: model?.attrs }); + }); + + server.delete('/rest/variables/:id', (schema: AppSchema, request: Request) => { + const id = request.params.id; + + const model = schema.find('variable', id); + if (model) { + model.destroy(); + } + + return new Response(200, {}, {}); + }); +} diff --git a/packages/editor-ui/src/__tests__/server/factories/index.ts b/packages/editor-ui/src/__tests__/server/factories/index.ts index 181ff9b9a1153..9b5155c28f1e8 100644 --- a/packages/editor-ui/src/__tests__/server/factories/index.ts +++ b/packages/editor-ui/src/__tests__/server/factories/index.ts @@ -1,13 +1,16 @@ import { userFactory } from './user'; import { credentialFactory } from './credential'; import { credentialTypeFactory } from './credentialType'; +import { variableFactory } from './variable'; export * from './user'; export * from './credential'; export * from './credentialType'; +export * from './variable'; export const factories = { credential: credentialFactory, credentialType: credentialTypeFactory, user: userFactory, + variable: variableFactory, }; diff --git a/packages/editor-ui/src/__tests__/server/factories/variable.ts b/packages/editor-ui/src/__tests__/server/factories/variable.ts new file mode 100644 index 0000000000000..948e2578888fd --- /dev/null +++ b/packages/editor-ui/src/__tests__/server/factories/variable.ts @@ -0,0 +1,15 @@ +import { Factory } from 'miragejs'; +import { faker } from '@faker-js/faker'; +import type { EnvironmentVariable } from '@/Interface'; + +export const variableFactory = Factory.extend({ + id(i: number) { + return i; + }, + key() { + return `${faker.lorem.word()}`.toUpperCase(); + }, + value() { + return faker.internet.password(10); + }, +}); diff --git a/packages/editor-ui/src/__tests__/server/index.ts b/packages/editor-ui/src/__tests__/server/index.ts index edff6894e7acc..7eb78caacad29 100644 --- a/packages/editor-ui/src/__tests__/server/index.ts +++ b/packages/editor-ui/src/__tests__/server/index.ts @@ -10,6 +10,8 @@ export function setupServer() { seeds(server) { server.createList('credentialType', 8); server.create('user', { + firstName: 'Nathan', + lastName: 'Doe', isDefaultUser: true, }); }, diff --git a/packages/editor-ui/src/__tests__/server/models/index.ts b/packages/editor-ui/src/__tests__/server/models/index.ts index 310b832a4d00d..6b9f39327b9bf 100644 --- a/packages/editor-ui/src/__tests__/server/models/index.ts +++ b/packages/editor-ui/src/__tests__/server/models/index.ts @@ -1,9 +1,11 @@ import { UserModel } from './user'; import { CredentialModel } from './credential'; import { CredentialTypeModel } from './credentialType'; +import { VariableModel } from './variable'; export const models = { credential: CredentialModel, credentialType: CredentialTypeModel, user: UserModel, + variable: VariableModel, }; diff --git a/packages/editor-ui/src/__tests__/server/models/variable.ts b/packages/editor-ui/src/__tests__/server/models/variable.ts new file mode 100644 index 0000000000000..5677a4db9b980 --- /dev/null +++ b/packages/editor-ui/src/__tests__/server/models/variable.ts @@ -0,0 +1,5 @@ +import { EnvironmentVariable } from '@/Interface'; +import { Model } from 'miragejs'; +import type { ModelDefinition } from 'miragejs/-types'; + +export const VariableModel: ModelDefinition = Model.extend({}); diff --git a/packages/editor-ui/src/api/environments.ee.ts b/packages/editor-ui/src/api/environments.ee.ts new file mode 100644 index 0000000000000..baf4280459e94 --- /dev/null +++ b/packages/editor-ui/src/api/environments.ee.ts @@ -0,0 +1,40 @@ +import { EnvironmentVariable, IRestApiContext } from '@/Interface'; +import { makeRestApiRequest } from '@/utils'; +import { IDataObject } from 'n8n-workflow'; + +export async function getVariables(context: IRestApiContext): Promise { + return await makeRestApiRequest(context, 'GET', '/variables'); +} + +export async function getVariable( + context: IRestApiContext, + { id }: { id: EnvironmentVariable['id'] }, +): Promise { + return await makeRestApiRequest(context, 'GET', `/variables/${id}`); +} + +export async function createVariable( + context: IRestApiContext, + data: Omit, +) { + return await makeRestApiRequest(context, 'POST', '/variables', data as unknown as IDataObject); +} + +export async function updateVariable( + context: IRestApiContext, + { id, ...data }: EnvironmentVariable, +) { + return await makeRestApiRequest( + context, + 'PATCH', + `/variables/${id}`, + data as unknown as IDataObject, + ); +} + +export async function deleteVariable( + context: IRestApiContext, + { id }: { id: EnvironmentVariable['id'] }, +) { + return await makeRestApiRequest(context, 'DELETE', `/variables/${id}`); +} diff --git a/packages/editor-ui/src/components/CodeEdit.vue b/packages/editor-ui/src/components/CodeEdit.vue index 9c7d87065576d..8198196d48c4f 100644 --- a/packages/editor-ui/src/components/CodeEdit.vue +++ b/packages/editor-ui/src/components/CodeEdit.vue @@ -135,6 +135,7 @@ export default mixins(genericHelpers, workflowHelpers).extend({ '$mode', '$parameter', '$resumeWebhookUrl', + '$vars', '$workflow', '$now', '$today', diff --git a/packages/editor-ui/src/components/CodeNodeEditor/completer.ts b/packages/editor-ui/src/components/CodeNodeEditor/completer.ts index d37e979499837..efebc8e76d7ac 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/completer.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/completer.ts @@ -13,6 +13,7 @@ import { luxonCompletions } from './completions/luxon.completions'; import { itemIndexCompletions } from './completions/itemIndex.completions'; import { itemFieldCompletions } from './completions/itemField.completions'; import { jsonFieldCompletions } from './completions/jsonField.completions'; +import { variablesCompletions } from './completions/variables.completions'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; import type { Extension } from '@codemirror/state'; @@ -24,6 +25,7 @@ export const completerExtension = mixins( requireCompletions, executionCompletions, workflowCompletions, + variablesCompletions, prevNodeCompletions, luxonCompletions, itemIndexCompletions, @@ -49,6 +51,7 @@ export const completerExtension = mixins( this.nodeSelectorCompletions, this.prevNodeCompletions, this.workflowCompletions, + this.variablesCompletions, this.executionCompletions, // luxon @@ -167,6 +170,7 @@ export const completerExtension = mixins( // core if (value === '$execution') return this.executionCompletions(context, variable); + if (value === '$vars') return this.variablesCompletions(context, variable); if (value === '$workflow') return this.workflowCompletions(context, variable); if (value === '$prevNode') return this.prevNodeCompletions(context, variable); diff --git a/packages/editor-ui/src/components/CodeNodeEditor/completions/base.completions.ts b/packages/editor-ui/src/components/CodeNodeEditor/completions/base.completions.ts index bd14af28870de..a9401714ebce3 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/completions/base.completions.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/completions/base.completions.ts @@ -67,6 +67,10 @@ export const baseCompletions = (Vue as CodeNodeEditorMixin).extend({ label: '$workflow', info: this.$locale.baseText('codeNodeEditor.completer.$workflow'), }, + { + label: '$vars', + info: this.$locale.baseText('codeNodeEditor.completer.$vars'), + }, { label: '$now', info: this.$locale.baseText('codeNodeEditor.completer.$now'), diff --git a/packages/editor-ui/src/components/CodeNodeEditor/completions/variables.completions.ts b/packages/editor-ui/src/components/CodeNodeEditor/completions/variables.completions.ts new file mode 100644 index 0000000000000..dc60e9d96359d --- /dev/null +++ b/packages/editor-ui/src/components/CodeNodeEditor/completions/variables.completions.ts @@ -0,0 +1,33 @@ +import Vue from 'vue'; +import { addVarType } from '../utils'; +import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; +import type { CodeNodeEditorMixin } from '../types'; +import { useEnvironmentsStore } from '@/stores'; + +const escape = (str: string) => str.replace('$', '\\$'); + +export const variablesCompletions = (Vue as CodeNodeEditorMixin).extend({ + methods: { + /** + * Complete `$workflow.` to `.id .name .active`. + */ + variablesCompletions(context: CompletionContext, matcher = '$vars'): CompletionResult | null { + const pattern = new RegExp(`${escape(matcher)}\..*`); + + const preCursor = context.matchBefore(pattern); + + if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null; + + const environmentsStore = useEnvironmentsStore(); + const options: Completion[] = environmentsStore.variables.map((variable) => ({ + label: `${matcher}.${variable.key}`, + info: variable.value, + })); + + return { + from: preCursor.from, + options: options.map(addVarType), + }; + }, + }, +}); diff --git a/packages/editor-ui/src/components/MainSidebar.vue b/packages/editor-ui/src/components/MainSidebar.vue index 1b7f142ead108..8809d660a259a 100644 --- a/packages/editor-ui/src/components/MainSidebar.vue +++ b/packages/editor-ui/src/components/MainSidebar.vue @@ -226,6 +226,14 @@ export default mixins( position: 'top', activateOnRouteNames: [VIEWS.CREDENTIALS], }, + { + id: 'variables', + icon: 'variable', + label: this.$locale.baseText('mainSidebar.variables'), + customIconSize: 'medium', + position: 'top', + activateOnRouteNames: [VIEWS.VARIABLES], + }, { id: 'executions', icon: 'tasks', @@ -374,6 +382,12 @@ export default mixins( } break; } + case 'variables': { + if (this.$router.currentRoute.name !== VIEWS.VARIABLES) { + this.goToRoute({ name: VIEWS.VARIABLES }); + } + break; + } case 'executions': { if (this.$router.currentRoute.name !== VIEWS.EXECUTIONS) { this.goToRoute({ name: VIEWS.EXECUTIONS }); diff --git a/packages/editor-ui/src/components/VariablesRow.vue b/packages/editor-ui/src/components/VariablesRow.vue new file mode 100644 index 0000000000000..4b1264659d3a0 --- /dev/null +++ b/packages/editor-ui/src/components/VariablesRow.vue @@ -0,0 +1,272 @@ + + + + + diff --git a/packages/editor-ui/src/components/__tests__/VariablesRow.spec.ts b/packages/editor-ui/src/components/__tests__/VariablesRow.spec.ts new file mode 100644 index 0000000000000..ac87e260b0eed --- /dev/null +++ b/packages/editor-ui/src/components/__tests__/VariablesRow.spec.ts @@ -0,0 +1,98 @@ +import VariablesRow from '../VariablesRow.vue'; +import { EnvironmentVariable } from '@/Interface'; +import { fireEvent, render } from '@testing-library/vue'; +import { createPinia, setActivePinia } from 'pinia'; +import { setupServer } from '@/__tests__/server'; +import { afterAll, beforeAll } from 'vitest'; +import { useSettingsStore, useUsersStore } from '@/stores'; + +describe('VariablesRow', () => { + let server: ReturnType; + + beforeAll(() => { + server = setupServer(); + }); + + beforeEach(async () => { + setActivePinia(createPinia()); + + await useSettingsStore().getSettings(); + await useUsersStore().loginWithCookie(); + }); + + afterAll(() => { + server.shutdown(); + }); + + const stubs = ['n8n-tooltip']; + + const environmentVariable: EnvironmentVariable = { + id: 1, + key: 'key', + value: 'value', + }; + + it('should render correctly', () => { + const wrapper = render(VariablesRow, { + props: { + data: environmentVariable, + }, + stubs, + }); + + expect(wrapper.html()).toMatchSnapshot(); + expect(wrapper.container.querySelectorAll('td')).toHaveLength(4); + }); + + it('should show edit and delete buttons on hover', async () => { + const wrapper = render(VariablesRow, { + props: { + data: environmentVariable, + }, + stubs, + }); + + await fireEvent.mouseEnter(wrapper.container); + + expect(wrapper.getByTestId('variable-row-edit-button')).toBeVisible(); + expect(wrapper.getByTestId('variable-row-delete-button')).toBeVisible(); + }); + + it('should show key and value inputs in edit mode', async () => { + const wrapper = render(VariablesRow, { + props: { + data: environmentVariable, + editing: true, + }, + stubs, + }); + + await fireEvent.mouseEnter(wrapper.container); + + expect(wrapper.getByTestId('variable-row-key-input')).toBeVisible(); + expect(wrapper.getByTestId('variable-row-key-input').querySelector('input')).toHaveValue( + environmentVariable.key, + ); + expect(wrapper.getByTestId('variable-row-value-input')).toBeVisible(); + expect(wrapper.getByTestId('variable-row-value-input').querySelector('input')).toHaveValue( + environmentVariable.value, + ); + + expect(wrapper.html()).toMatchSnapshot(); + }); + + it('should show cancel and save buttons in edit mode', async () => { + const wrapper = render(VariablesRow, { + props: { + data: environmentVariable, + editing: true, + }, + stubs, + }); + + await fireEvent.mouseEnter(wrapper.container); + + expect(wrapper.getByTestId('variable-row-cancel-button')).toBeVisible(); + expect(wrapper.getByTestId('variable-row-save-button')).toBeVisible(); + }); +}); diff --git a/packages/editor-ui/src/components/__tests__/__snapshots__/VariablesRow.spec.ts.snap b/packages/editor-ui/src/components/__tests__/__snapshots__/VariablesRow.spec.ts.snap new file mode 100644 index 0000000000000..8d8404c7e5f19 --- /dev/null +++ b/packages/editor-ui/src/components/__tests__/__snapshots__/VariablesRow.spec.ts.snap @@ -0,0 +1,78 @@ +// Vitest Snapshot v1 + +exports[`VariablesRow > should render correctly 1`] = ` +"
+
key
+
+
value
+
+
+ $vars.key +
+
+
+ +
+
+ +
+
+
+
+
+
+ +
+
+ + + + + +
+
+ +
+
+
+
+
+ +
+
+ + + + + +
+
+ +
+
+
+
+ $vars.key +
+
+
+