From 48ef2a41c0fde6037d3ada875cedd28e7df8baf9 Mon Sep 17 00:00:00 2001 From: Valya Bullions Date: Tue, 28 Feb 2023 13:54:50 +0000 Subject: [PATCH 01/21] feat: add variables db models and migrations --- packages/cli/src/Db.ts | 1 + packages/cli/src/Interfaces.ts | 2 ++ .../cli/src/databases/entities/Variables.ts | 16 ++++++++++ packages/cli/src/databases/entities/index.ts | 2 ++ .../mysqldb/1677501636753-CreateVariables.ts | 32 +++++++++++++++++++ .../src/databases/migrations/mysqldb/index.ts | 2 ++ .../1677501636754-CreateVariables.ts | 31 ++++++++++++++++++ .../databases/migrations/postgresdb/index.ts | 2 ++ .../sqlite/1677501636752-CreateVariables.ts | 31 ++++++++++++++++++ .../src/databases/migrations/sqlite/index.ts | 2 ++ 10 files changed, 121 insertions(+) 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 diff --git a/packages/cli/src/Db.ts b/packages/cli/src/Db.ts index 68ff6a0adaf38..1f099e31d5d2c 100644 --- a/packages/cli/src/Db.ts +++ b/packages/cli/src/Db.ts @@ -170,6 +170,7 @@ export async function init( collections.InstalledNodes = linkRepository(entities.InstalledNodes); collections.WorkflowStatistics = linkRepository(entities.WorkflowStatistics); collections.EventDestinations = linkRepository(entities.EventDestinations); + collections.Variables = linkRepository(entities.Variables); isInitialized = true; diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index d1ea87ffac06e..e523c88cec0ef 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -44,6 +44,7 @@ import type { SharedCredentials } from '@db/entities/SharedCredentials'; import type { SharedWorkflow } from '@db/entities/SharedWorkflow'; import type { TagEntity } from '@db/entities/TagEntity'; import type { User } from '@db/entities/User'; +import type { Variables } from './databases/entities/Variables'; import type { WebhookEntity } from '@db/entities/WebhookEntity'; import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import type { WorkflowStatistics } from '@db/entities/WorkflowStatistics'; @@ -88,6 +89,7 @@ export interface IDatabaseCollections { InstalledNodes: Repository; WorkflowStatistics: Repository; EventDestinations: Repository; + Variables: Repository; } // ---------------------------------- diff --git a/packages/cli/src/databases/entities/Variables.ts b/packages/cli/src/databases/entities/Variables.ts new file mode 100644 index 0000000000000..fb8b38e3463d4 --- /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') + 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 17ff382044afc..9b8983b57ba3f 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 { WorkflowStatistics } from './WorkflowStatistics'; @@ -30,6 +31,7 @@ export const entities = { SharedWorkflow, TagEntity, User, + Variables, WebhookEntity, WorkflowEntity, WorkflowStatistics, 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..c101586103a71 --- /dev/null +++ b/packages/cli/src/databases/migrations/mysqldb/1677501636753-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 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\` TEXT NOT NULL, + \`type\` TEXT DEFAULT 'string' NOT NULL, + value TEXT NULL + ) + 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 b67441e08f26c..398e8e2864ecd 100644 --- a/packages/cli/src/databases/migrations/mysqldb/index.ts +++ b/packages/cli/src/databases/migrations/mysqldb/index.ts @@ -34,6 +34,7 @@ import { PurgeInvalidWorkflowConnections1675940580449 } from './1675940580449-Pu import { AddStatusToExecutions1674138566000 } from './1674138566000-AddStatusToExecutions'; import { MigrateExecutionStatus1676996103000 } from './1676996103000-MigrateExecutionStatus'; import { UpdateRunningExecutionStatus1677236788851 } from './1677236788851-UpdateRunningExecutionStatus'; +import { CreateVariables1677501636753 } from './1677501636753-CreateVariables'; export const mysqlMigrations = [ InitialMigration1588157391238, @@ -72,4 +73,5 @@ export const mysqlMigrations = [ AddStatusToExecutions1674138566000, MigrateExecutionStatus1676996103000, UpdateRunningExecutionStatus1677236788851, + 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..221fe656cf195 --- /dev/null +++ b/packages/cli/src/databases/migrations/postgresdb/1677501636754-CreateVariables.ts @@ -0,0 +1,31 @@ +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" text NOT NULL, + "type" text NOT NULL DEFAULT 'string', + value text NULL + ); + `); + + 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 9aa75b16ed1db..3c5aa921ebd5e 100644 --- a/packages/cli/src/databases/migrations/postgresdb/index.ts +++ b/packages/cli/src/databases/migrations/postgresdb/index.ts @@ -32,6 +32,7 @@ import { PurgeInvalidWorkflowConnections1675940580449 } from './1675940580449-Pu import { AddStatusToExecutions1674138566000 } from './1674138566000-AddStatusToExecutions'; import { MigrateExecutionStatus1676996103000 } from './1676996103000-MigrateExecutionStatus'; import { UpdateRunningExecutionStatus1677236854063 } from './1677236854063-UpdateRunningExecutionStatus'; +import { CreateVariables1677501636754 } from './1677501636754-CreateVariables'; export const postgresMigrations = [ InitialMigration1587669153312, @@ -68,4 +69,5 @@ export const postgresMigrations = [ AddStatusToExecutions1674138566000, MigrateExecutionStatus1676996103000, UpdateRunningExecutionStatus1677236854063, + 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..9505145800a6d --- /dev/null +++ b/packages/cli/src/databases/migrations/sqlite/1677501636752-CreateVariables.ts @@ -0,0 +1,31 @@ +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 + ); + `); + + 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 42782c2430c7b..a73c005d38468 100644 --- a/packages/cli/src/databases/migrations/sqlite/index.ts +++ b/packages/cli/src/databases/migrations/sqlite/index.ts @@ -31,6 +31,7 @@ import { PurgeInvalidWorkflowConnections1675940580449 } from './1675940580449-Pu import { AddStatusToExecutions1674138566000 } from './1674138566000-AddStatusToExecutions'; import { MigrateExecutionStatus1676996103000 } from './1676996103000-MigrateExecutionStatus'; import { UpdateRunningExecutionStatus1677237073720 } from './1677237073720-UpdateRunningExecutionStatus'; +import { CreateVariables1677501636752 } from './1677501636752-CreateVariables'; const sqliteMigrations = [ InitialMigration1588102412422, @@ -66,6 +67,7 @@ const sqliteMigrations = [ AddStatusToExecutions1674138566000, MigrateExecutionStatus1676996103000, UpdateRunningExecutionStatus1677237073720, + CreateVariables1677501636752, ]; export { sqliteMigrations }; From 1eb521a85383ab18e7655acb051d231b5af1cad5 Mon Sep 17 00:00:00 2001 From: Valya Bullions Date: Thu, 2 Mar 2023 17:15:18 +0000 Subject: [PATCH 02/21] feat: variables api endpoints --- packages/cli/src/Server.ts | 7 + .../cli/src/databases/entities/Variables.ts | 2 +- .../mysqldb/1677501636753-CreateVariables.ts | 3 +- .../1677501636754-CreateVariables.ts | 3 +- .../sqlite/1677501636752-CreateVariables.ts | 3 +- .../src/environments/variables.controller.ts | 98 +++++++ .../cli/src/environments/variables.service.ts | 26 ++ 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 | 3 + .../cli/test/integration/variables.test.ts | 259 ++++++++++++++++++ 12 files changed, 444 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/environments/variables.controller.ts create mode 100644 packages/cli/src/environments/variables.service.ts create mode 100644 packages/cli/test/integration/variables.test.ts diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index af3a5b5e56482..493eeec69187b 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -149,6 +149,7 @@ import { getSamlLoginLabel, isSamlLoginEnabled, isSamlLicensed } from './sso/sam import { samlControllerPublic } from './sso/saml/routes/saml.controller.public.ee'; import { SamlService } from './sso/saml/saml.service.ee'; import { samlControllerProtected } from './sso/saml/routes/saml.controller.protected.ee'; +import { variablesController } from './environments/variables.controller'; const exec = promisify(callbackExec); @@ -522,6 +523,12 @@ class Server extends AbstractServer { this.app.use(`/${this.restEndpoint}/sso/saml`, samlControllerPublic); this.app.use(`/${this.restEndpoint}/sso/saml`, samlControllerProtected); + // ---------------------------------------- + // Variables + // ---------------------------------------- + + this.app.use(`/${this.restEndpoint}/variables`, variablesController); + // ---------------------------------------- // Returns parameter values which normally get loaded from an external API or diff --git a/packages/cli/src/databases/entities/Variables.ts b/packages/cli/src/databases/entities/Variables.ts index fb8b38e3463d4..64eef5a9fc8f9 100644 --- a/packages/cli/src/databases/entities/Variables.ts +++ b/packages/cli/src/databases/entities/Variables.ts @@ -8,7 +8,7 @@ export class Variables { @Column('text') key: string; - @Column('text') + @Column('text', { default: 'string' }) type: string; @Column('text') diff --git a/packages/cli/src/databases/migrations/mysqldb/1677501636753-CreateVariables.ts b/packages/cli/src/databases/migrations/mysqldb/1677501636753-CreateVariables.ts index c101586103a71..422f07856fbff 100644 --- a/packages/cli/src/databases/migrations/mysqldb/1677501636753-CreateVariables.ts +++ b/packages/cli/src/databases/migrations/mysqldb/1677501636753-CreateVariables.ts @@ -13,7 +13,8 @@ export class CreateVariables1677501636753 implements MigrationInterface { id int(11) auto_increment NOT NULL PRIMARY KEY, \`key\` TEXT NOT NULL, \`type\` TEXT DEFAULT 'string' NOT NULL, - value TEXT NULL + value TEXT NULL, + UNIQUE (\`key\`) ) ENGINE=InnoDB; `); diff --git a/packages/cli/src/databases/migrations/postgresdb/1677501636754-CreateVariables.ts b/packages/cli/src/databases/migrations/postgresdb/1677501636754-CreateVariables.ts index 221fe656cf195..9ee7369e4bf4c 100644 --- a/packages/cli/src/databases/migrations/postgresdb/1677501636754-CreateVariables.ts +++ b/packages/cli/src/databases/migrations/postgresdb/1677501636754-CreateVariables.ts @@ -13,7 +13,8 @@ export class CreateVariables1677501636754 implements MigrationInterface { id serial4 NOT NULL PRIMARY KEY, "key" text NOT NULL, "type" text NOT NULL DEFAULT 'string', - value text NULL + value text NULL, + UNIQUE ("key") ); `); diff --git a/packages/cli/src/databases/migrations/sqlite/1677501636752-CreateVariables.ts b/packages/cli/src/databases/migrations/sqlite/1677501636752-CreateVariables.ts index 9505145800a6d..da6265defa9e1 100644 --- a/packages/cli/src/databases/migrations/sqlite/1677501636752-CreateVariables.ts +++ b/packages/cli/src/databases/migrations/sqlite/1677501636752-CreateVariables.ts @@ -13,7 +13,8 @@ export class CreateVariables1677501636752 implements MigrationInterface { id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "key" TEXT NOT NULL, "type" TEXT NOT NULL DEFAULT ('string'), - value TEXT + value TEXT, + UNIQUE("key") ); `); diff --git a/packages/cli/src/environments/variables.controller.ts b/packages/cli/src/environments/variables.controller.ts new file mode 100644 index 0000000000000..2d0fd40a49ee3 --- /dev/null +++ b/packages/cli/src/environments/variables.controller.ts @@ -0,0 +1,98 @@ +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'; + +export const variablesController = express.Router(); + +/** + * Initialize Logger if needed + */ +variablesController.use((req, res, next) => { + try { + LoggerProxy.getInstance(); + } catch (error) { + LoggerProxy.init(getLogger()); + } + next(); +}); + +variablesController.get( + '/', + ResponseHelper.send(async () => { + return VariablesService.getAll(); + }), +); + +variablesController.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; + return VariablesService.create(variable); + }), +); + +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 (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; + return VariablesService.update(id, variable); + }), +); + +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.ts b/packages/cli/src/environments/variables.service.ts new file mode 100644 index 0000000000000..cdeb99b55f522 --- /dev/null +++ b/packages/cli/src/environments/variables.service.ts @@ -0,0 +1,26 @@ +import type { Variables } from '@/databases/entities/Variables'; +import { collections } from '@/Db'; + +export class VariablesService { + static async getAll(): Promise { + return collections.Variables.find(); + } + + static async get(id: number): Promise { + return collections.Variables.findOne({ where: { id } }); + } + + static async create(variable: Omit): Promise { + return collections.Variables.save(variable); + } + + static async update(id: number, variable: Omit): Promise { + await collections.Variables.update(id, variable); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return (await this.get(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 f8c5af17b4a84..6429b8658d8ce 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 * as UserManagementMailer from '@/UserManagement/email/UserManagementMailer'; +import type { Variables } from '@db/entities/Variables'; export class UserUpdatePayload implements Pick { @IsEmail() @@ -373,3 +374,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 4b26eb1625857..b788bc9552462 100644 --- a/packages/cli/test/integration/shared/testDb.ts +++ b/packages/cli/test/integration/shared/testDb.ts @@ -499,6 +499,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 bd739897588bd..b77ae6cfa4c58 100644 --- a/packages/cli/test/integration/shared/types.d.ts +++ b/packages/cli/test/integration/shared/types.d.ts @@ -23,7 +23,8 @@ type EndpointGroup = | 'nodes' | 'ldap' | '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 ba28f16732656..21e8133835853 100644 --- a/packages/cli/test/integration/shared/utils.ts +++ b/packages/cli/test/integration/shared/utils.ts @@ -79,6 +79,7 @@ import { ldapController } from '@/Ldap/routes/ldap.controller.ee'; import { InternalHooks } from '@/InternalHooks'; import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; import { PostHogClient } from '@/posthog'; +import { variablesController } from '@/environments/variables.controller'; export const mockInstance = ( ctor: new (...args: any[]) => T, @@ -159,6 +160,7 @@ export async function initTestServer({ license: { controller: licenseController, path: 'license' }, eventBus: { controller: eventBusRouter, path: 'eventbus' }, ldap: { controller: ldapController, path: 'ldap' }, + variables: { controller: variablesController, path: 'variables' }, }; if (enablePublicAPI) { @@ -254,6 +256,7 @@ const classifyEndpointGroups = (endpointGroups: EndpointGroup[]) => { 'ldap', 'eventBus', 'license', + 'variables', ]; endpointGroups.forEach((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..d5b70e061a3c5 --- /dev/null +++ b/packages/cli/test/integration/variables.test.ts @@ -0,0 +1,259 @@ +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'; + +// mock that credentialsSharing is not enabled +let app: Application; +let ownerUser: User; +let memberUser: User; +let authAgent: AuthAgent; + +beforeAll(async () => { + app = await utils.initTestServer({ endpointGroups: ['variables'] }); + + utils.initConfigFile(); + + ownerUser = await testDb.createOwner(); + memberUser = await testDb.createUser(); + + authAgent = utils.createAuthAgent(app); +}); + +beforeEach(async () => { + await testDb.truncate(['Variables']); +}); + +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); +}); + +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'); +}); + +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 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('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); +}); + +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); +}); From af4ffcb5fb7b302181e9477badda4af7fd2646c1 Mon Sep 17 00:00:00 2001 From: Valya Bullions Date: Thu, 2 Mar 2023 17:49:34 +0000 Subject: [PATCH 03/21] feat: add $variables to expressions --- packages/cli/src/WorkflowExecuteAdditionalData.ts | 6 +++++- packages/cli/src/WorkflowHelpers.ts | 9 +++++++++ packages/core/src/NodeExecuteFunctions.ts | 1 + packages/nodes-base/test/nodes/Helpers.ts | 1 + packages/workflow/src/Interfaces.ts | 1 + 5 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/WorkflowExecuteAdditionalData.ts b/packages/cli/src/WorkflowExecuteAdditionalData.ts index 4488af126cb8c..0ee25ee3d74d5 100644 --- a/packages/cli/src/WorkflowExecuteAdditionalData.ts +++ b/packages/cli/src/WorkflowExecuteAdditionalData.ts @@ -1146,7 +1146,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), @@ -1161,6 +1164,7 @@ export async function getBase( executionTimeoutTimestamp, userId, setExecutionStatus, + variables, }; } diff --git a/packages/cli/src/WorkflowHelpers.ts b/packages/cli/src/WorkflowHelpers.ts index b2d01cd0ead1e..d90fafba99a12 100644 --- a/packages/cli/src/WorkflowHelpers.ts +++ b/packages/cli/src/WorkflowHelpers.ts @@ -574,3 +574,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/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index b7179460ec8f0..5ce78804b0257 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -1598,6 +1598,7 @@ export function getAdditionalKeys( mode: mode === 'manual' ? 'test' : 'production', resumeUrl, }, + $variables: additionalData.variables, // deprecated $executionId: executionId, diff --git a/packages/nodes-base/test/nodes/Helpers.ts b/packages/nodes-base/test/nodes/Helpers.ts index 83fb957b2ee11..d49cc4e4c36b4 100644 --- a/packages/nodes-base/test/nodes/Helpers.ts +++ b/packages/nodes-base/test/nodes/Helpers.ts @@ -112,6 +112,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 1f9372c2c36cd..e582d247c2391 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -1673,6 +1673,7 @@ export interface IWorkflowExecuteAdditionalData { currentNodeParameters?: INodeParameters; executionTimeoutTimestamp?: number; userId: string; + variables: IDataObject; } export type WorkflowExecuteMode = From 26b58dc75dd36e21275f3c0727c61406aeee1532 Mon Sep 17 00:00:00 2001 From: Valya Bullions Date: Mon, 13 Mar 2023 15:16:37 +0000 Subject: [PATCH 04/21] test: fix ActiveWorkflowRunner tests failing --- packages/cli/src/WorkflowHelpers.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/cli/src/WorkflowHelpers.ts b/packages/cli/src/WorkflowHelpers.ts index d90fafba99a12..e654799163d6f 100644 --- a/packages/cli/src/WorkflowHelpers.ts +++ b/packages/cli/src/WorkflowHelpers.ts @@ -576,6 +576,9 @@ export function validateWorkflowCredentialUsage( } export async function getVariables(): Promise { + if (!Db.collections.Variables) { + return {}; + } return Object.freeze( (await Db.collections.Variables.find()).reduce((prev, curr) => { prev[curr.key] = curr.value; From 3f94ea65c0a57ca5236850627720cb6b3e53cce8 Mon Sep 17 00:00:00 2001 From: Valya Bullions Date: Tue, 14 Mar 2023 14:59:20 +0000 Subject: [PATCH 05/21] test: a different fix for the tests broken by $variables --- packages/cli/src/WorkflowHelpers.ts | 3 --- packages/cli/test/unit/ActiveWorkflowRunner.test.ts | 3 +++ 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/WorkflowHelpers.ts b/packages/cli/src/WorkflowHelpers.ts index e654799163d6f..d90fafba99a12 100644 --- a/packages/cli/src/WorkflowHelpers.ts +++ b/packages/cli/src/WorkflowHelpers.ts @@ -576,9 +576,6 @@ export function validateWorkflowCredentialUsage( } export async function getVariables(): Promise { - if (!Db.collections.Variables) { - return {}; - } return Object.freeze( (await Db.collections.Variables.find()).reduce((prev, curr) => { prev[curr.key] = curr.value; diff --git a/packages/cli/test/unit/ActiveWorkflowRunner.test.ts b/packages/cli/test/unit/ActiveWorkflowRunner.test.ts index 395b85d377260..44225b9e67edb 100644 --- a/packages/cli/test/unit/ActiveWorkflowRunner.test.ts +++ b/packages/cli/test/unit/ActiveWorkflowRunner.test.ts @@ -119,6 +119,9 @@ jest.mock('@/Db', () => { clear: jest.fn(), delete: jest.fn(), }, + Variables: { + find: jest.fn(() => []), + }, }, }; }); From edf6a00f899d68e5dbc00e7b73442b6b4315fc9b Mon Sep 17 00:00:00 2001 From: Valya Bullions Date: Wed, 15 Mar 2023 16:26:51 +0000 Subject: [PATCH 06/21] feat: variables licensing --- packages/cli/src/Interfaces.ts | 4 +++ packages/cli/src/License.ts | 8 +++++ packages/cli/src/Server.ts | 10 ++++++ packages/cli/src/config/schema.ts | 4 +++ packages/cli/src/constants.ts | 1 + .../cli/src/environments/enviromentHelpers.ts | 26 ++++++++++++++ .../src/environments/variables.controller.ts | 11 ++++-- .../cli/src/environments/variables.service.ts | 10 ++++++ .../cli/test/integration/variables.test.ts | 34 +++++++++++++++++++ 9 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/environments/enviromentHelpers.ts diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index 986d94437a4e7..263a29039a95f 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -527,11 +527,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/License.ts b/packages/cli/src/License.ts index 35ed8360606ec..0b55457e2c981 100644 --- a/packages/cli/src/License.ts +++ b/packages/cli/src/License.ts @@ -110,6 +110,10 @@ export class License { return this.isFeatureEnabled(LICENSE_FEATURES.ADVANCED_EXECUTION_FILTERS); } + isVariablesEnabled() { + return this.isFeatureEnabled(LICENSE_FEATURES.VARIABLES); + } + getCurrentEntitlements() { return this.manager?.getCurrentEntitlements() ?? []; } @@ -156,6 +160,10 @@ export class License { return (this.getFeatureValue('quota:activeWorkflows') ?? -1) as number; } + getVariablesLimit(): number { + return (this.getFeatureValue('quota:maxVariables') ?? -1) as number; + } + getPlanName(): string { return (this.getFeatureValue('planName') ?? 'Community') as string; } diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index d2f9b9e9a86e2..a51fe13dbffb6 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -157,6 +157,7 @@ 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'; const exec = promisify(callbackExec); @@ -309,11 +310,15 @@ class Server extends AbstractServer { saml: false, logStreaming: config.getEnv('enterprise.features.logStreaming'), advancedExecutionFilters: config.getEnv('enterprise.features.advancedExecutionFilters'), + variables: false, }, hideUsagePage: config.getEnv('hideUsagePage'), license: { environment: config.getEnv('license.tenantId') === 1 ? 'production' : 'staging', }, + variables: { + limit: 0, + }, }; } @@ -338,6 +343,7 @@ class Server extends AbstractServer { ldap: isLdapEnabled(), saml: isSamlLicensed(), advancedExecutionFilters: isAdvancedExecutionFiltersEnabled(), + variables: isVariablesEnabled(), }); if (isLdapEnabled()) { @@ -354,6 +360,10 @@ class Server extends AbstractServer { }); } + if (isVariablesEnabled()) { + this.frontendSettings.variables.limit = getVariablesLimit(); + } + if (config.get('nodes.packagesMissing').length > 0) { this.frontendSettings.missingPackages = true; } diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 5d9cb40045740..e629e5d55aa3b 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -1012,6 +1012,10 @@ export const schema = { format: Boolean, default: false, }, + variables: { + format: Boolean, + default: false, + }, }, }, diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index 2a984afb54cba..a30aca82463d5 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -73,6 +73,7 @@ export enum LICENSE_FEATURES { SAML = 'feat:saml', LOG_STREAMING = 'feat:logStreaming', ADVANCED_EXECUTION_FILTERS = 'feat:advancedExecutionFilters', + VARIABLES = 'feat:variables', } export const CREDENTIAL_BLANKING_VALUE = '__n8n_BLANK_VALUE_e5362baf-c777-4d57-a609-6eaf1f9e87f6'; diff --git a/packages/cli/src/environments/enviromentHelpers.ts b/packages/cli/src/environments/enviromentHelpers.ts new file mode 100644 index 0000000000000..78901bdb1efbe --- /dev/null +++ b/packages/cli/src/environments/enviromentHelpers.ts @@ -0,0 +1,26 @@ +import config from '@/config'; +import { getLicense } from '@/License'; + +export function isVariablesEnabled(): boolean { + const license = getLicense(); + return config.getEnv('enterprise.features.variables') || license.isVariablesEnabled(); +} + +export function canCreateNewVariable(variableCount: number): boolean { + if (!isVariablesEnabled()) { + return false; + } + const license = getLicense(); + // 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 = getLicense(); + return license.getVariablesLimit(); +} diff --git a/packages/cli/src/environments/variables.controller.ts b/packages/cli/src/environments/variables.controller.ts index 2d0fd40a49ee3..e586a069e0345 100644 --- a/packages/cli/src/environments/variables.controller.ts +++ b/packages/cli/src/environments/variables.controller.ts @@ -4,7 +4,7 @@ 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 { VariablesLicenseError, VariablesService } from './variables.service'; export const variablesController = express.Router(); @@ -38,7 +38,14 @@ variablesController.post( } const variable = req.body; delete variable.id; - return VariablesService.create(variable); + try { + return await VariablesService.create(variable); + } catch (error) { + if (error instanceof VariablesLicenseError) { + throw new ResponseHelper.BadRequestError(error.message); + } + throw error; + } }), ); diff --git a/packages/cli/src/environments/variables.service.ts b/packages/cli/src/environments/variables.service.ts index cdeb99b55f522..d145e3bbaf0f3 100644 --- a/packages/cli/src/environments/variables.service.ts +++ b/packages/cli/src/environments/variables.service.ts @@ -1,16 +1,26 @@ import type { Variables } from '@/databases/entities/Variables'; import { collections } from '@/Db'; +import { canCreateNewVariable } from './enviromentHelpers'; + +export class VariablesLicenseError extends Error {} 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 create(variable: Omit): Promise { + if (!canCreateNewVariable(await this.getCount())) { + throw new VariablesLicenseError('Variables limit reached'); + } return collections.Variables.save(variable); } diff --git a/packages/cli/test/integration/variables.test.ts b/packages/cli/test/integration/variables.test.ts index d5b70e061a3c5..2b99f2b8f03a0 100644 --- a/packages/cli/test/integration/variables.test.ts +++ b/packages/cli/test/integration/variables.test.ts @@ -1,8 +1,10 @@ import type { Application } from 'express'; import type { User } from '@/databases/entities/User'; +import config from '@/config'; import * as testDb from './shared/testDb'; import * as utils from './shared/utils'; +import * as EnvironmentHelpers from '@/environments/enviromentHelpers'; import type { AuthAgent } from './shared/types'; @@ -25,6 +27,7 @@ beforeAll(async () => { beforeEach(async () => { await testDb.truncate(['Variables']); + config.set('enterprise.features.variables', true); }); afterAll(async () => { @@ -57,6 +60,10 @@ test('GET /variables should return all variables for a member', async () => { 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'), @@ -87,6 +94,10 @@ test('GET /variables/:id should return a single variable for a member', async () 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', @@ -125,6 +136,21 @@ test('POST /variables should not create a new credential and return it for a mem expect(byKey).toBeNull(); }); +test("POST /variables should not create a new credential and return it if the instance doesn't have a license", async () => { + config.set('enterprise.features.variables', 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', @@ -137,6 +163,10 @@ test('POST /variables should fail to create a new credential and if one with the 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 = { @@ -224,6 +254,10 @@ test('PATCH /variables/:id should not modify existing credential if one with the 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'), From 99c87789077a7778854edb4d5b0c63bf111b0d2c Mon Sep 17 00:00:00 2001 From: Valya Bullions Date: Thu, 16 Mar 2023 12:12:40 +0000 Subject: [PATCH 07/21] fix: could create one extra variable than licensed for --- packages/cli/src/environments/enviromentHelpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/environments/enviromentHelpers.ts b/packages/cli/src/environments/enviromentHelpers.ts index 78901bdb1efbe..c74b99d69984a 100644 --- a/packages/cli/src/environments/enviromentHelpers.ts +++ b/packages/cli/src/environments/enviromentHelpers.ts @@ -17,7 +17,7 @@ export function canCreateNewVariable(variableCount: number): boolean { if (limit === -1) { return true; } - return limit <= variableCount; + return limit < variableCount; } export function getVariablesLimit(): number { From 724818da938f5f1b4c7c8654033aeb52c300c32e Mon Sep 17 00:00:00 2001 From: Alex Grozav Date: Tue, 28 Mar 2023 10:44:02 +0200 Subject: [PATCH 08/21] 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 --- packages/core/src/NodeExecuteFunctions.ts | 2 +- .../src/components/N8nDatatable/Datatable.vue | 54 ++--- .../N8nDatatable/__tests__/Datatable.spec.ts | 27 ++- .../__snapshots__/Datatable.spec.ts.snap | 20 +- .../src/plugins/n8nComponents.ts | 2 + .../mixins.ts => types/datatable.ts} | 2 +- packages/design-system/src/types/index.ts | 1 + packages/editor-ui/package.json | 5 +- 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/__tests__/setup.ts | 4 + 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 | 153 ++++++++++++++ .../components/__tests__/VariablesRow.spec.ts | 80 +++++++ .../__snapshots__/VariablesRow.spec.ts.snap | 16 ++ .../layouts/ResourcesListLayout.vue | 93 +++++--- 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 + .../completions/datatype.completions.ts | 10 +- packages/editor-ui/src/plugins/components.ts | 65 +----- packages/editor-ui/src/plugins/i18n/index.ts | 1 + .../src/plugins/i18n/locales/en.json | 37 ++++ packages/editor-ui/src/plugins/icons.ts | 1 + 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 + .../editor-ui/src/views/CredentialsView.vue | 2 +- packages/editor-ui/src/views/NodeView.vue | 10 +- .../editor-ui/src/views/VariablesView.vue | 200 ++++++++++++++++++ .../src/views/__tests__/VariablesView.spec.ts | 49 +++++ pnpm-lock.yaml | 22 +- 55 files changed, 1461 insertions(+), 143 deletions(-) rename packages/design-system/src/{components/N8nDatatable/mixins.ts => types/datatable.ts} (78%) 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/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/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 9983ba5f526a7..eb7f241bfc591 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -1625,7 +1625,7 @@ export function getAdditionalKeys( mode: mode === 'manual' ? 'test' : 'production', resumeUrl, }, - $variables: additionalData.variables, + $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 475e14e8df7d3..5c47cf0a7348b 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/editor-ui/src/components/__tests__/VariablesRow.spec.ts b/packages/editor-ui/src/components/__tests__/VariablesRow.spec.ts new file mode 100644 index 0000000000000..15ed679879bb8 --- /dev/null +++ b/packages/editor-ui/src/components/__tests__/VariablesRow.spec.ts @@ -0,0 +1,80 @@ +import VariablesRow from '../VariablesRow.vue'; +import { EnvironmentVariable } from '@/Interface'; +import { fireEvent, render } from '@testing-library/vue'; +import { createPinia, setActivePinia } from 'pinia'; + +describe('VariablesRow', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + 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, + ); + }); + + 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..2a7493420c79c --- /dev/null +++ b/packages/editor-ui/src/components/__tests__/__snapshots__/VariablesRow.spec.ts.snap @@ -0,0 +1,16 @@ +// Vitest Snapshot v1 + +exports[`VariablesRow > should render correctly 1`] = ` +" + key + value + + $vars.key + + +
+ +" +`; diff --git a/packages/editor-ui/src/components/layouts/ResourcesListLayout.vue b/packages/editor-ui/src/components/layouts/ResourcesListLayout.vue index 3c5a8e7601634..173b3a439ef0a 100644 --- a/packages/editor-ui/src/components/layouts/ResourcesListLayout.vue +++ b/packages/editor-ui/src/components/layouts/ResourcesListLayout.vue @@ -11,6 +11,7 @@ @@ -75,23 +76,14 @@
- - - - - - +
+ + + + + + +
{{ $locale.baseText(`${resourceKey}.noResults`) }} @@ -177,6 +181,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 +218,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,12 +249,30 @@ export default mixins(showMessage, debounceHelper).extend({ type: Boolean, default: true, }, + showFiltersDropdown: { + type: Boolean, + default: true, + }, + 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, resettingFilters: false, EnterpriseEditionFeature, @@ -292,7 +319,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) { @@ -309,9 +336,9 @@ export default mixins(showMessage, debounceHelper).extend({ case 'lastCreated': return new Date(b.createdAt).valueOf() - new Date(a.createdAt).valueOf(); case 'nameAsc': - return a.name.trim().localeCompare(b.name.trim()); + return this.displayName(a).trim().localeCompare(this.displayName(a).trim()); case 'nameDesc': - return b.name.localeCompare(a.name); + return this.displayName(b).localeCompare(this.displayName(a)); default: return 0; } 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 3607bb8f25de8..0d8ea8742ace8 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -365,6 +365,7 @@ export enum VIEWS { TEMPLATE = 'TemplatesWorkflowView', TEMPLATES = 'TemplatesSearchView', CREDENTIALS = 'CredentialsView', + VARIABLES = 'VariablesView', NEW_WORKFLOW = 'NodeViewNew', WORKFLOW = 'NodeViewExisting', DEMO = 'WorkflowDemo', @@ -426,6 +427,7 @@ export const MAPPING_PARAMS = [ '$resumeWebhookUrl', '$runIndex', '$today', + '$vars', '$workflow', ]; @@ -448,6 +450,7 @@ export enum EnterpriseEditionFeature { Sharing = 'sharing', Ldap = 'ldap', LogStreaming = 'logStreaming', + Variables = 'variables', } 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 e3bdecac32d5c..5fc69ba9bf46b 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; @@ -177,6 +178,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/plugins/codemirror/completions/datatype.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts index 1362354167ff8..9461a58e1c6e1 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[] = []; @@ -42,6 +44,12 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul } else { let resolved: Resolved; + if (base === '$vars') { + const environmentsStore = useEnvironmentsStore(); + const variables = environmentsStore.variablesAsObject; + base = JSON.stringify(variables); + } + try { resolved = resolveParameter(`={{ ${base} }}`); } catch { 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 956e0c960452a..d6dcde471c931 100644 --- a/packages/editor-ui/src/plugins/i18n/index.ts +++ b/packages/editor-ui/src/plugins/i18n/index.ts @@ -338,6 +338,7 @@ export class I18nClass { $prevNode: this.baseText('codeNodeEditor.completer.$prevNode'), $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 166cf93e4dec3..171a4bb4b41bf 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -130,6 +130,7 @@ "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.$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", @@ -562,6 +563,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", @@ -1568,6 +1570,33 @@ "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.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.row.button.save": "Save", + "variables.row.button.cancel": "Cancel", + "variables.row.button.edit": "Edit", + "variables.row.button.delete": "Delete", + "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 this variable?", + "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", @@ -1592,6 +1621,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 data that can be referenced easily across multiple workflows.", + "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.ts b/packages/editor-ui/src/plugins/icons.ts index 3d67c20fc2d42..f3a6b53c8b8d0 100644 --- a/packages/editor-ui/src/plugins/icons.ts +++ b/packages/editor-ui/src/plugins/icons.ts @@ -15,6 +15,7 @@ import { faBook, faBoxOpen, faBug, + faBracketsCurly, faCalculator, faCalendar, faChartBar, diff --git a/packages/editor-ui/src/router.ts b/packages/editor-ui/src/router.ts index 02a33d0d44d7d..758e29b430cb1 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'; @@ -185,6 +186,21 @@ const router = new Router({ }, }, }, + { + 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 5915acdd43aa6..1bafe2822c713 100644 --- a/packages/editor-ui/src/stores/ui.ts +++ b/packages/editor-ui/src/stores/ui.ts @@ -207,6 +207,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/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 86461bc09b304..89a8bed859fa6 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -240,7 +240,7 @@ import { TelemetryHelpers, Workflow, } from 'n8n-workflow'; -import { +import type { ICredentialsResponse, IExecutionResponse, IWorkflowDb, @@ -277,7 +277,8 @@ import { useTagsStore } from '@/stores/tags'; import { useNodeCreatorStore } from '@/stores/nodeCreator'; import { dataPinningEventBus } from '@/event-bus/data-pinning-event-bus'; 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'; @@ -472,6 +473,7 @@ export default mixins( useWorkflowsStore, useUsersStore, useNodeCreatorStore, + useEnvironmentsStore, useWorkflowsEEStore, useHistoryStore, ), @@ -3600,6 +3602,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; @@ -3853,6 +3858,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..328ec7a3e13c5 --- /dev/null +++ b/packages/editor-ui/src/views/VariablesView.vue @@ -0,0 +1,200 @@ + + + + + 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/pnpm-lock.yaml b/pnpm-lock.yaml index ec0e3abd711cb..d8d83f12ae543 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -592,6 +592,7 @@ importers: c8: ^7.12.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 @@ -623,7 +624,7 @@ importers: vitest: ^0.28.5 vue: ^2.7.14 vue-agile: ^2.0.0 - vue-fragment: 1.5.1 + vue-fragment: 1.6.0 vue-i18n: ^8.26.7 vue-infinite-loading: ^2.4.5 vue-json-pretty: 1.9.3 @@ -657,6 +658,7 @@ importers: axios: 0.21.4 codemirror-lang-html-n8n: 1.0.0 codemirror-lang-n8n-expression: 0.2.0_zyklskjzaprvz25ee7sq7godcq + copy-to-clipboard: 3.3.3 dateformat: 3.0.3 esprima-next: 5.8.4 fast-json-stable-stringify: 2.1.0 @@ -680,7 +682,7 @@ importers: v-click-outside: 3.2.0 vue: 2.7.14 vue-agile: 2.0.0 - vue-fragment: 1.5.1_vue@2.7.14 + vue-fragment: 1.6.0_vue@2.7.14 vue-i18n: 8.27.2_vue@2.7.14 vue-infinite-loading: 2.4.5_vue@2.7.14 vue-json-pretty: 1.9.3 @@ -6562,7 +6564,7 @@ packages: vite: ^3.0.0 || ^4.0.0 vue: ^2.7.0-0 dependencies: - vite: 4.0.4_sass@1.58.0 + vite: 4.0.4_sass@1.55.0+terser@5.16.1 vue: 2.7.14 dev: true @@ -9245,6 +9247,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 @@ -19744,6 +19752,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'} @@ -21090,8 +21102,8 @@ packages: - supports-color dev: true - /vue-fragment/1.5.1_vue@2.7.14: - resolution: {integrity: sha512-ig6eES6TcMBbANW71ylB+AJgRN+Zksb3f50AxjGpAk6hMzqmeuD80qeh4LJP0jVw2dMBMjgRUfIkrvxygoRgtQ==} + /vue-fragment/1.6.0_vue@2.7.14: + resolution: {integrity: sha512-a5T8ZZZK/EQzgVShEl374HbobUJ0a7v12BzOzS6Z/wd/5EE/5SffcyHC+7bf9hP3L7Yc0hhY/GhMdwFQ25O/8A==} peerDependencies: vue: ^2.5.16 dependencies: From afd8905590277d89fecb253d55af58366df5984f Mon Sep 17 00:00:00 2001 From: Valya Bullions Date: Tue, 4 Apr 2023 11:41:21 +0100 Subject: [PATCH 09/21] refactor: review changes --- packages/cli/src/License.ts | 11 +++- packages/cli/src/constants.ts | 5 ++ .../environments/variables.controller.ee.ts | 64 +++++++++++++++++++ .../src/environments/variables.controller.ts | 38 ++--------- .../src/environments/variables.service.ee.ts | 25 ++++++++ .../cli/src/environments/variables.service.ts | 16 ----- 6 files changed, 107 insertions(+), 52 deletions(-) create mode 100644 packages/cli/src/environments/variables.controller.ee.ts create mode 100644 packages/cli/src/environments/variables.service.ee.ts diff --git a/packages/cli/src/License.ts b/packages/cli/src/License.ts index 82aa2ad01ac48..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 { @@ -166,11 +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('quota:maxVariables') ?? -1) as number; + return (this.getFeatureValue(LICENSE_QUOTAS.VARIABLES_LIMIT) ?? -1) as number; } getPlanName(): string { diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index b89b57413c41c..938739069aa2a 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -77,4 +77,9 @@ export enum LICENSE_FEATURES { 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/environments/variables.controller.ee.ts b/packages/cli/src/environments/variables.controller.ee.ts new file mode 100644 index 0000000000000..b50b27f660e60 --- /dev/null +++ b/packages/cli/src/environments/variables.controller.ee.ts @@ -0,0 +1,64 @@ +import express from 'express'; +import { LoggerProxy } from 'n8n-workflow'; + +import * as ResponseHelper from '@/ResponseHelper'; +import type { VariablesRequest } from '@/requests'; +import { VariablesLicenseError, EEVariablesService } 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); + } + 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; + return EEVariablesService.update(id, variable); + }), +); diff --git a/packages/cli/src/environments/variables.controller.ts b/packages/cli/src/environments/variables.controller.ts index e586a069e0345..29cc5ed7aec49 100644 --- a/packages/cli/src/environments/variables.controller.ts +++ b/packages/cli/src/environments/variables.controller.ts @@ -4,7 +4,7 @@ import { LoggerProxy } from 'n8n-workflow'; import { getLogger } from '@/Logger'; import * as ResponseHelper from '@/ResponseHelper'; import type { VariablesRequest } from '@/requests'; -import { VariablesLicenseError, VariablesService } from './variables.service'; +import { VariablesService } from './variables.service'; export const variablesController = express.Router(); @@ -29,23 +29,8 @@ variablesController.get( variablesController.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 VariablesService.create(variable); - } catch (error) { - if (error instanceof VariablesLicenseError) { - throw new ResponseHelper.BadRequestError(error.message); - } - throw error; - } + ResponseHelper.send(async () => { + throw new ResponseHelper.BadRequestError('No variables license found'); }), ); @@ -66,21 +51,8 @@ variablesController.get( variablesController.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; - return VariablesService.update(id, variable); + ResponseHelper.send(async () => { + throw new ResponseHelper.BadRequestError('No variables license found'); }), ); 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..95586b7e503e2 --- /dev/null +++ b/packages/cli/src/environments/variables.service.ee.ts @@ -0,0 +1,25 @@ +import type { Variables } from '@/databases/entities/Variables'; +import { collections } from '@/Db'; +import { canCreateNewVariable } from './enviromentHelpers'; +import { VariablesService } from './variables.service'; + +export class VariablesLicenseError extends Error {} + +export class EEVariablesService extends VariablesService { + static async getCount(): Promise { + return collections.Variables.count(); + } + + static async create(variable: Omit): Promise { + if (!canCreateNewVariable(await this.getCount())) { + throw new VariablesLicenseError('Variables limit reached'); + } + return collections.Variables.save(variable); + } + + static async update(id: number, variable: Omit): Promise { + 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 index d145e3bbaf0f3..646f9368f2382 100644 --- a/packages/cli/src/environments/variables.service.ts +++ b/packages/cli/src/environments/variables.service.ts @@ -1,8 +1,5 @@ import type { Variables } from '@/databases/entities/Variables'; import { collections } from '@/Db'; -import { canCreateNewVariable } from './enviromentHelpers'; - -export class VariablesLicenseError extends Error {} export class VariablesService { static async getAll(): Promise { @@ -17,19 +14,6 @@ export class VariablesService { return collections.Variables.findOne({ where: { id } }); } - static async create(variable: Omit): Promise { - if (!canCreateNewVariable(await this.getCount())) { - throw new VariablesLicenseError('Variables limit reached'); - } - return collections.Variables.save(variable); - } - - static async update(id: number, variable: Omit): Promise { - await collections.Variables.update(id, variable); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return (await this.get(id))!; - } - static async delete(id: number): Promise { await collections.Variables.delete(id); } From 4aadc910aa0d88b0483c0334a7204864c8a91c86 Mon Sep 17 00:00:00 2001 From: Valya Bullions Date: Tue, 4 Apr 2023 13:27:00 +0100 Subject: [PATCH 10/21] feat: add variable creation telemetry --- packages/cli/src/Interfaces.ts | 1 + packages/cli/src/InternalHooks.ts | 4 ++++ packages/cli/src/environments/variables.service.ee.ts | 3 +++ 3 files changed, 8 insertions(+) diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index 4c1ca70179a67..2f9a2e20db09c 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -450,6 +450,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 { diff --git a/packages/cli/src/InternalHooks.ts b/packages/cli/src/InternalHooks.ts index ea36dc04b8627..ca490dd478c75 100644 --- a/packages/cli/src/InternalHooks.ts +++ b/packages/cli/src/InternalHooks.ts @@ -978,4 +978,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/environments/variables.service.ee.ts b/packages/cli/src/environments/variables.service.ee.ts index 95586b7e503e2..f13339058a2d3 100644 --- a/packages/cli/src/environments/variables.service.ee.ts +++ b/packages/cli/src/environments/variables.service.ee.ts @@ -1,5 +1,7 @@ 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'; @@ -14,6 +16,7 @@ export class EEVariablesService extends VariablesService { if (!canCreateNewVariable(await this.getCount())) { throw new VariablesLicenseError('Variables limit reached'); } + void Container.get(InternalHooks).onVariableCreated({ variable_type: variable.type }); return collections.Variables.save(variable); } From dae6e21662c65de0d3896fda1c6f7abe29590caa Mon Sep 17 00:00:00 2001 From: Alex Grozav Date: Tue, 4 Apr 2023 16:56:34 +0300 Subject: [PATCH 11/21] 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 --- .../src/components/N8nDatatable/Datatable.vue | 4 +- .../N8nDatatable/__tests__/Datatable.spec.ts | 15 ++++ packages/design-system/src/types/datatable.ts | 1 + .../editor-ui/src/components/VariablesRow.vue | 25 +++++- .../components/__tests__/VariablesRow.spec.ts | 19 ++++- .../__snapshots__/VariablesRow.spec.ts.snap | 37 +++++++- .../layouts/ResourcesListLayout.vue | 28 +++--- .../src/plugins/i18n/locales/en.json | 5 +- .../editor-ui/src/views/VariablesView.vue | 85 ++++++++++++++----- 9 files changed, 176 insertions(+), 43 deletions(-) diff --git a/packages/design-system/src/components/N8nDatatable/Datatable.vue b/packages/design-system/src/components/N8nDatatable/Datatable.vue index 5c47cf0a7348b..909e7f0db1cab 100644 --- a/packages/design-system/src/components/N8nDatatable/Datatable.vue +++ b/packages/design-system/src/components/N8nDatatable/Datatable.vue @@ -102,7 +102,7 @@ export default defineComponent({ - @@ -111,7 +111,7 @@ export default defineComponent({ + +
+ + @@ -336,9 +342,9 @@ export default mixins(showMessage, debounceHelper).extend({ case 'lastCreated': return new Date(b.createdAt).valueOf() - new Date(a.createdAt).valueOf(); case 'nameAsc': - return this.displayName(a).trim().localeCompare(this.displayName(a).trim()); + return this.displayName(a).trim().localeCompare(this.displayName(b).trim()); case 'nameDesc': - return this.displayName(b).localeCompare(this.displayName(a)); + return this.displayName(b).trim().localeCompare(this.displayName(a).trim()); default: return 0; } diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 2f4fce0c893f4..3cde247ebeeb1 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -1600,6 +1600,7 @@ "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.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.", @@ -1622,7 +1623,7 @@ "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 this 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", @@ -1652,7 +1653,7 @@ "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 data that can be referenced easily across multiple workflows.", + "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.", "contextual.variables.unavailable.button": "View plans", "contextual.variables.unavailable.button.cloud": "Upgrade now", "contextual.variables.unavailable.button.desktop": "View plans", diff --git a/packages/editor-ui/src/views/VariablesView.vue b/packages/editor-ui/src/views/VariablesView.vue index 328ec7a3e13c5..3bbb45724ff9b 100644 --- a/packages/editor-ui/src/views/VariablesView.vue +++ b/packages/editor-ui/src/views/VariablesView.vue @@ -29,7 +29,11 @@ const TEMPORARY_VARIABLE_UID_BASE = '@tmpvar'; const allVariables = ref>([]); const editMode = ref>({}); -const datatableColumns = ref([ +const isFeatureEnabled = computed(() => + settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Variables), +); + +const datatableColumns = computed(() => [ { id: 0, path: 'name', @@ -44,17 +48,19 @@ const datatableColumns = ref([ id: 2, path: 'usage', label: i18n.baseText('variables.table.usage'), + classes: ['variables-usage-column'], }, - { - id: 3, - path: 'actions', - label: '', - }, + ...(isFeatureEnabled.value + ? [ + { + id: 3, + path: 'actions', + label: '', + }, + ] + : []), ]); -const isFeatureEnabled = computed(() => - settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Variables), -); const contextBasedTranslationKeys = computed(() => uiStore.contextBasedTranslationKeys); const { upgradeLinkUrl } = useUpgradeLink({ default: '&source=variables', @@ -117,16 +123,16 @@ function cancelEditing(data: EnvironmentVariable | TemporaryEnvironmentVariable) } async function deleteVariable(data: EnvironmentVariable) { - const confirmed = await message.confirm( - i18n.baseText('variables.modals.deleteConfirm.message'), - i18n.baseText('variables.modals.deleteConfirm.title'), - { - confirmButtonText: i18n.baseText('variables.modals.deleteConfirm.confirmButton'), - cancelButtonText: i18n.baseText('variables.modals.deleteConfirm.cancelButton'), - }, - ); - - if (!confirmed) { + try { + await message.confirm( + i18n.baseText('variables.modals.deleteConfirm.message', { interpolate: { name: data.key } }), + i18n.baseText('variables.modals.deleteConfirm.title'), + { + confirmButtonText: i18n.baseText('variables.modals.deleteConfirm.confirmButton'), + cancelButtonText: i18n.baseText('variables.modals.deleteConfirm.cancelButton'), + }, + ); + } catch (e) { return; } @@ -160,8 +166,39 @@ function displayName(resource: EnvironmentVariable) { :showFiltersDropdown="false" type="datatable" :type-props="{ columns: datatableColumns }" - @click:add="addTemporaryVariable" > + + -
+
@@ -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" />
+ {{ column.label }}
@@ -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" />
{{ $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/VariablesRow.vue b/packages/editor-ui/src/components/VariablesRow.vue index 31fb76ca63b38..4b1264659d3a0 100644 --- a/packages/editor-ui/src/components/VariablesRow.vue +++ b/packages/editor-ui/src/components/VariablesRow.vue @@ -1,14 +1,16 @@