Compare commits

...

13 Commits

Author SHA1 Message Date
James Gee
68c8aab6cc fix: linting and comment 2025-12-05 11:09:09 +01:00
James Gee
974ff6615c fix: linting and comment 2025-12-05 10:57:17 +01:00
James Gee
367e7947a3 fix: linting issues & cubic issue 2025-12-05 10:57:15 +01:00
James Gee
a42e1bde6a fix: labels on chart 2025-12-05 10:57:13 +01:00
James Gee
db7bd59e8c fix: PAY-4106 - Make workers memory constraints more container aware 2025-12-05 10:57:11 +01:00
Jaakko Husso
480d1e609b feat(core): Put Chat users behind license checks (no-changelog) (#22781) 2025-12-05 11:38:12 +02:00
Nikhil Kuriakose
b22654709a feat(editor): Rename columns in data tables (#21747) 2025-12-05 10:06:54 +01:00
Suguru Inoue
8d7f438e1f fix(editor): Fix chat telemetry (no-changelog) (#22793) 2025-12-05 10:04:20 +01:00
Milorad FIlipović
829135ceee feat(editor): Open template setup modal automatically (no-changelog) (#22596) 2025-12-05 09:54:05 +01:00
Declan Carroll
3f382a0369 test: Fixing flaky/failing workflow action test (#22792) 2025-12-05 08:37:55 +00:00
Jaakko Husso
54ca0c1abc fix(core): Filter out workflows from custom agents that use too old agents (no-changelog) (#22752) 2025-12-05 00:53:02 +02:00
Artem Sorokin
e219e7e915 test: Move auth tests to separate folder (#22726) 2025-12-04 23:01:55 +01:00
Declan Carroll
6e77f0eb81 ci: GH bot has a bypass for our CLA (#22773) 2025-12-04 21:23:59 +00:00
54 changed files with 2700 additions and 134 deletions

View File

@@ -56,5 +56,5 @@ jobs:
branch: update-node-popularity
base: master
delete-branch: true
author: n8n Bot <191478365+n8n-bot@users.noreply.github.com>
committer: n8n Bot <191478365+n8n-bot@users.noreply.github.com>
author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
committer: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

View File

@@ -0,0 +1,7 @@
import { Z } from 'zod-class';
import { dataTableColumnNameSchema } from '../../schemas/data-table.schema';
export class RenameDataTableColumnDto extends Z.class({
name: dataTableColumnNameSchema,
}) {}

View File

@@ -99,6 +99,7 @@ export { CreateDataTableColumnDto } from './data-table/create-data-table-column.
export { AddDataTableRowsDto } from './data-table/add-data-table-rows.dto';
export { AddDataTableColumnDto } from './data-table/add-data-table-column.dto';
export { MoveDataTableColumnDto } from './data-table/move-data-table-column.dto';
export { RenameDataTableColumnDto } from './data-table/rename-data-table-column.dto';
export {
OAuthClientResponseDto,

View File

@@ -13,6 +13,23 @@ export type RunningJobSummary = {
export type WorkerStatus = {
senderId: string;
runningJobsSummary: RunningJobSummary[];
isInContainer: boolean;
process: {
memory: {
available: number;
constraint: number;
rss: number;
heapTotal: number;
heapUsed: number;
};
uptime: number;
};
host: {
memory: {
total: number;
free: number;
};
};
freeMem: number;
totalMem: number;
uptime: number;

View File

@@ -1,14 +1,15 @@
import type { LicenseState } from '@n8n/backend-common';
import type { AuthenticatedRequest, SharedCredentialsRepository, CredentialsEntity } from '@n8n/db';
import { GLOBAL_OWNER_ROLE, GLOBAL_MEMBER_ROLE } from '@n8n/db';
import { mock } from 'jest-mock-extended';
import { createRawProjectData } from '@/__tests__/project.test-data';
import type { EventService } from '@/events/event.service';
import { createdCredentialsWithScopes, createNewCredentialsPayload } from './credentials.test-data';
import type { CredentialsFinderService } from '../credentials-finder.service';
import { CredentialsController } from '../credentials.controller';
import type { CredentialsService } from '../credentials.service';
import type { CredentialsFinderService } from '../credentials-finder.service';
import { createRawProjectData } from '@/__tests__/project.test-data';
import type { EventService } from '@/events/event.service';
import type { CredentialRequest } from '@/requests';
describe('CredentialsController', () => {
@@ -16,13 +17,14 @@ describe('CredentialsController', () => {
const credentialsService = mock<CredentialsService>();
const sharedCredentialsRepository = mock<SharedCredentialsRepository>();
const credentialsFinderService = mock<CredentialsFinderService>();
const licenseState = mock<LicenseState>();
const credentialsController = new CredentialsController(
mock(),
credentialsService,
mock(),
mock(),
mock(),
licenseState,
mock(),
mock(),
sharedCredentialsRepository,
@@ -126,7 +128,7 @@ describe('CredentialsController', () => {
] as any);
});
it('should allow owner to set isGlobal to true', async () => {
it('should not allow owner to set isGlobal to true if not licensed', async () => {
// ARRANGE
const ownerReq = {
user: { id: 'owner-id', role: GLOBAL_OWNER_ROLE },
@@ -139,6 +141,34 @@ describe('CredentialsController', () => {
},
} as unknown as CredentialRequest.Update;
licenseState.isSharingLicensed.mockReturnValue(false);
credentialsFinderService.findCredentialForUser.mockResolvedValue(existingCredential);
// ACT
await expect(credentialsController.updateCredentials(ownerReq)).rejects.toThrowError(
'You are not licensed for sharing credentials',
);
// ASSERT
expect(credentialsService.update).not.toHaveBeenCalled();
});
it('should allow owner to set isGlobal to true if licensed', async () => {
// ARRANGE
const ownerReq = {
user: { id: 'owner-id', role: GLOBAL_OWNER_ROLE },
params: { credentialId },
body: {
name: 'Updated Credential',
type: 'apiKey',
data: { apiKey: 'updated-key' },
isGlobal: true,
},
} as unknown as CredentialRequest.Update;
licenseState.isSharingLicensed.mockReturnValue(true);
credentialsFinderService.findCredentialForUser.mockResolvedValue(existingCredential);
credentialsService.update.mockResolvedValue({
...existingCredential,
@@ -163,7 +193,7 @@ describe('CredentialsController', () => {
});
});
it('should allow owner to set isGlobal to false', async () => {
it('should allow owner to set isGlobal to false if licensed', async () => {
// ARRANGE
const globalCredential = mock<CredentialsEntity>({
...existingCredential,
@@ -180,6 +210,8 @@ describe('CredentialsController', () => {
},
} as unknown as CredentialRequest.Update;
licenseState.isSharingLicensed.mockReturnValue(true);
credentialsFinderService.findCredentialForUser.mockResolvedValue(globalCredential);
credentialsService.update.mockResolvedValue({
...globalCredential,
@@ -198,7 +230,7 @@ describe('CredentialsController', () => {
);
});
it('should prevent non-owner from changing isGlobal', async () => {
it('should prevent non-owner from changing isGlobal if licensed', async () => {
// ARRANGE
const memberReq = {
user: { id: 'member-id', role: GLOBAL_MEMBER_ROLE },
@@ -211,6 +243,8 @@ describe('CredentialsController', () => {
},
} as unknown as CredentialRequest.Update;
licenseState.isSharingLicensed.mockReturnValue(true);
credentialsFinderService.findCredentialForUser.mockResolvedValue(existingCredential);
// ACT
@@ -235,6 +269,8 @@ describe('CredentialsController', () => {
},
} as unknown as CredentialRequest.Update;
licenseState.isSharingLicensed.mockReturnValue(true);
credentialsFinderService.findCredentialForUser.mockResolvedValue({
...existingCredential,
isGlobal: true,

View File

@@ -4,7 +4,7 @@ import {
CredentialsGetOneRequestQuery,
GenerateCredentialNameRequestQuery,
} from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { LicenseState, Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import {
SharedCredentials,
@@ -40,7 +40,6 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { EventService } from '@/events/event.service';
import { License } from '@/license';
import { listQueryMiddleware } from '@/middlewares';
import { CredentialRequest } from '@/requests';
import { NamingService } from '@/services/naming.service';
@@ -54,7 +53,7 @@ export class CredentialsController {
private readonly credentialsService: CredentialsService,
private readonly enterpriseCredentialsService: EnterpriseCredentialsService,
private readonly namingService: NamingService,
private readonly license: License,
private readonly licenseState: LicenseState,
private readonly logger: Logger,
private readonly userManagementMailer: UserManagementMailer,
private readonly sharedCredentialsRepository: SharedCredentialsRepository,
@@ -114,7 +113,7 @@ export class CredentialsController {
@Param('credentialId') credentialId: string,
@Query query: CredentialsGetOneRequestQuery,
) {
const { shared, ...credential } = this.license.isSharingEnabled()
const { shared, ...credential } = this.licenseState.isSharingLicensed()
? await this.enterpriseCredentialsService.getOne(
req.user,
credentialId,
@@ -246,6 +245,10 @@ export class CredentialsController {
// Update isGlobal if provided in the payload and user has permission
const isGlobal = body.isGlobal;
if (isGlobal !== undefined && isGlobal !== credential.isGlobal) {
if (!this.licenseState.isSharingLicensed()) {
throw new ForbiddenError('You are not licensed for sharing credentials');
}
const canShareGlobally = hasGlobalScope(req.user, 'credential:shareGlobally');
if (!canShareGlobally) {
throw new ForbiddenError(

View File

@@ -28,6 +28,7 @@ import type { Response } from 'express';
import { ErrorReporter, InstanceSettings } from 'n8n-core';
import {
CHAT_TRIGGER_NODE_TYPE,
AGENT_LANGCHAIN_NODE_TYPE,
OperationalError,
ManualExecutionCancelledError,
type INodeCredentials,
@@ -940,7 +941,6 @@ export class ChatHubService {
}
const chatTrigger = activeVersion.nodes?.find((node) => node.type === CHAT_TRIGGER_NODE_TYPE);
if (!chatTrigger) {
continue;
}
@@ -950,6 +950,15 @@ export class ChatHubService {
continue;
}
const agentNodes = activeVersion.nodes?.filter(
(node) => node.type === AGENT_LANGCHAIN_NODE_TYPE,
);
// Agents older than this can't do streaming
if (agentNodes.some((node) => node.typeVersion < 2.1)) {
continue;
}
const inputModalities = this.chatHubWorkflowService.parseInputModalities(
chatTriggerParams.options,
);

View File

@@ -0,0 +1,352 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import {
createTeamProject,
getPersonalProject,
linkUserToProject,
testDb,
} from '@n8n/backend-test-utils';
import type { Project, User } from '@n8n/db';
import { Container } from '@n8n/di';
import { createDataTable } from '@test-integration/db/data-tables';
import { createOwner, createMember, createAdmin } from '@test-integration/db/users';
import type { SuperAgentTest } from '@test-integration/types';
import * as utils from '@test-integration/utils';
import { DataTableColumnRepository } from '../data-table-column.repository';
import { mockDataTableSizeValidator } from './test-helpers';
let owner: User;
let member: User;
let admin: User;
let authOwnerAgent: SuperAgentTest;
let authMemberAgent: SuperAgentTest;
let authAdminAgent: SuperAgentTest;
let ownerProject: Project;
let memberProject: Project;
const testServer = utils.setupTestServer({
endpointGroups: ['data-table'],
modules: ['data-table'],
});
let dataTableColumnRepository: DataTableColumnRepository;
beforeAll(async () => {
mockDataTableSizeValidator();
dataTableColumnRepository = Container.get(DataTableColumnRepository);
owner = await createOwner();
member = await createMember();
admin = await createAdmin();
authOwnerAgent = testServer.authAgentFor(owner);
authMemberAgent = testServer.authAgentFor(member);
authAdminAgent = testServer.authAgentFor(admin);
ownerProject = await getPersonalProject(owner);
memberProject = await getPersonalProject(member);
});
beforeEach(async () => {
await testDb.truncate(['DataTable', 'DataTableColumn']);
});
describe('PATCH /projects/:projectId/data-tables/:dataTableId/columns/:columnId/rename', () => {
test('should not rename column when project does not exist', async () => {
const payload = {
name: 'new_column_name',
};
await authOwnerAgent
.patch(
'/projects/non-existing-id/data-tables/some-data-table-id/columns/some-column-id/rename',
)
.send(payload)
.expect(404);
});
test('should not rename column when data table does not exist', async () => {
const project = await createTeamProject('test project', owner);
const payload = {
name: 'new_column_name',
};
await authOwnerAgent
.patch(
`/projects/${project.id}/data-tables/non-existing-data-table/columns/some-column-id/rename`,
)
.send(payload)
.expect(404);
});
test('should not rename column when column does not exist', async () => {
const project = await createTeamProject('test project', owner);
const dataTable = await createDataTable(project, {
columns: [
{
name: 'test_column',
type: 'string',
},
],
});
const payload = {
name: 'new_column_name',
};
await authOwnerAgent
.patch(
`/projects/${project.id}/data-tables/${dataTable.id}/columns/non-existing-column-id/rename`,
)
.send(payload)
.expect(404);
});
test("should not rename column in another user's personal project data table", async () => {
const dataTable = await createDataTable(ownerProject, {
columns: [
{
name: 'test_column',
type: 'string',
},
],
});
await authMemberAgent
.patch(
`/projects/${ownerProject.id}/data-tables/${dataTable.id}/columns/${dataTable.columns[0].id}/rename`,
)
.send({ name: 'new_name' })
.expect(403);
const columnInDb = await dataTableColumnRepository.findOneBy({
id: dataTable.columns[0].id,
});
expect(columnInDb?.name).toBe('test_column');
});
test('should not rename column if user has project:viewer role in team project', async () => {
const project = await createTeamProject('test project', owner);
await linkUserToProject(member, project, 'project:viewer');
const dataTable = await createDataTable(project, {
columns: [
{
name: 'test_column',
type: 'string',
},
],
});
await authMemberAgent
.patch(
`/projects/${project.id}/data-tables/${dataTable.id}/columns/${dataTable.columns[0].id}/rename`,
)
.send({ name: 'new_name' })
.expect(403);
const columnInDb = await dataTableColumnRepository.findOneBy({
id: dataTable.columns[0].id,
});
expect(columnInDb?.name).toBe('test_column');
});
test('should rename column if user has project:editor role in team project', async () => {
const project = await createTeamProject('test project', owner);
await linkUserToProject(member, project, 'project:editor');
const dataTable = await createDataTable(project, {
columns: [
{
name: 'test_column',
type: 'string',
},
],
});
await authMemberAgent
.patch(
`/projects/${project.id}/data-tables/${dataTable.id}/columns/${dataTable.columns[0].id}/rename`,
)
.send({ name: 'renamed_column' })
.expect(200);
const columnInDb = await dataTableColumnRepository.findOneBy({
id: dataTable.columns[0].id,
});
expect(columnInDb?.name).toBe('renamed_column');
});
test('should rename column if user has project:admin role in team project', async () => {
const project = await createTeamProject('test project', owner);
await linkUserToProject(admin, project, 'project:admin');
const dataTable = await createDataTable(project, {
columns: [
{
name: 'test_column',
type: 'string',
},
],
});
await authAdminAgent
.patch(
`/projects/${project.id}/data-tables/${dataTable.id}/columns/${dataTable.columns[0].id}/rename`,
)
.send({ name: 'renamed_column' })
.expect(200);
const columnInDb = await dataTableColumnRepository.findOneBy({
id: dataTable.columns[0].id,
});
expect(columnInDb?.name).toBe('renamed_column');
});
test('should rename column if user is owner in team project', async () => {
const project = await createTeamProject('test project', owner);
const dataTable = await createDataTable(project, {
columns: [
{
name: 'test_column',
type: 'string',
},
],
});
await authOwnerAgent
.patch(
`/projects/${project.id}/data-tables/${dataTable.id}/columns/${dataTable.columns[0].id}/rename`,
)
.send({ name: 'renamed_column' })
.expect(200);
const columnInDb = await dataTableColumnRepository.findOneBy({
id: dataTable.columns[0].id,
});
expect(columnInDb?.name).toBe('renamed_column');
});
test('should rename column in personal project', async () => {
const dataTable = await createDataTable(memberProject, {
columns: [
{
name: 'test_column',
type: 'string',
},
],
});
await authMemberAgent
.patch(
`/projects/${memberProject.id}/data-tables/${dataTable.id}/columns/${dataTable.columns[0].id}/rename`,
)
.send({ name: 'renamed_column' })
.expect(200);
const columnInDb = await dataTableColumnRepository.findOneBy({
id: dataTable.columns[0].id,
});
expect(columnInDb?.name).toBe('renamed_column');
});
test('should not rename column to an existing column name', async () => {
const project = await createTeamProject('test project', owner);
const dataTable = await createDataTable(project, {
columns: [
{
name: 'first_column',
type: 'string',
},
{
name: 'second_column',
type: 'string',
},
],
});
await authOwnerAgent
.patch(
`/projects/${project.id}/data-tables/${dataTable.id}/columns/${dataTable.columns[0].id}/rename`,
)
.send({ name: 'second_column' })
.expect(409);
const firstColumnInDb = await dataTableColumnRepository.findOneBy({
id: dataTable.columns[0].id,
});
expect(firstColumnInDb?.name).toBe('first_column');
});
test('should not rename column with invalid column name', async () => {
const project = await createTeamProject('test project', owner);
const dataTable = await createDataTable(project, {
columns: [
{
name: 'test_column',
type: 'string',
},
],
});
await authOwnerAgent
.patch(
`/projects/${project.id}/data-tables/${dataTable.id}/columns/${dataTable.columns[0].id}/rename`,
)
.send({ name: 'invalid name with spaces' })
.expect(400);
const columnInDb = await dataTableColumnRepository.findOneBy({
id: dataTable.columns[0].id,
});
expect(columnInDb?.name).toBe('test_column');
});
test('should not rename column with empty name', async () => {
const project = await createTeamProject('test project', owner);
const dataTable = await createDataTable(project, {
columns: [
{
name: 'test_column',
type: 'string',
},
],
});
await authOwnerAgent
.patch(
`/projects/${project.id}/data-tables/${dataTable.id}/columns/${dataTable.columns[0].id}/rename`,
)
.send({ name: '' })
.expect(400);
const columnInDb = await dataTableColumnRepository.findOneBy({
id: dataTable.columns[0].id,
});
expect(columnInDb?.name).toBe('test_column');
});
test('should rename column successfully', async () => {
const project = await createTeamProject('test project', owner);
const dataTable = await createDataTable(project, {
columns: [
{
name: 'original_name',
type: 'string',
},
],
});
await authOwnerAgent
.patch(
`/projects/${project.id}/data-tables/${dataTable.id}/columns/${dataTable.columns[0].id}/rename`,
)
.send({ name: 'updated_name' })
.expect(200);
// Verify column name changed
const columnInDb = await dataTableColumnRepository.findOneBy({
id: dataTable.columns[0].id,
});
expect(columnInDb?.name).toBe('updated_name');
});
});

View File

@@ -0,0 +1,250 @@
import { testModules } from '@n8n/backend-test-utils';
import type { DataSource, EntityManager } from '@n8n/typeorm';
import { mock } from 'jest-mock-extended';
import { DataTableColumn } from '../data-table-column.entity';
import { DataTableColumnRepository } from '../data-table-column.repository';
import type { DataTableDDLService } from '../data-table-ddl.service';
import { DataTable } from '../data-table.entity';
import { DataTableColumnNameConflictError } from '../errors/data-table-column-name-conflict.error';
import { DataTableSystemColumnNameConflictError } from '../errors/data-table-system-column-name-conflict.error';
describe('DataTableColumnRepository', () => {
let repository: DataTableColumnRepository;
let mockDataSource: DataSource;
let mockDDLService: jest.Mocked<DataTableDDLService>;
let mockEntityManager: jest.Mocked<EntityManager>;
beforeAll(async () => {
await testModules.loadModules(['data-table']);
});
beforeEach(() => {
mockDDLService = mock<DataTableDDLService>();
mockEntityManager = mock<EntityManager>({
connection: {
options: { type: 'postgres' },
} as any,
});
// Mock the transaction method to execute the callback immediately
(mockEntityManager.transaction as jest.Mock) = jest.fn(
async (callback: (em: EntityManager) => Promise<any>) => {
return await callback(mockEntityManager);
},
);
mockDataSource = mock<DataSource>({
manager: mockEntityManager,
});
repository = new DataTableColumnRepository(mockDataSource, mockDDLService);
});
describe('renameColumn', () => {
const dataTableId = 'test-table-id';
const mockColumn: DataTableColumn = {
id: 'column-id',
name: 'old_name',
type: 'string',
index: 0,
dataTableId,
} as DataTableColumn;
describe('validateUniqueColumnName', () => {
it('should throw DataTableColumnNameConflictError when column name already exists', async () => {
// Arrange
const newName = 'duplicate_name';
const dataTable = { id: dataTableId, name: 'Test Table' } as DataTable;
mockEntityManager.existsBy.mockResolvedValue(true);
mockEntityManager.findOneBy.mockResolvedValue(dataTable);
// Act & Assert
await expect(repository.renameColumn(dataTableId, mockColumn, newName)).rejects.toThrow(
DataTableColumnNameConflictError,
);
await expect(repository.renameColumn(dataTableId, mockColumn, newName)).rejects.toThrow(
`Data table column with name '${newName}' already exists in data table '${dataTable.name}'`,
);
expect(mockEntityManager.existsBy).toHaveBeenCalledWith(DataTableColumn, {
name: newName,
dataTableId,
});
expect(mockEntityManager.findOneBy).toHaveBeenCalledWith(DataTable, { id: dataTableId });
});
it('should not throw when column name is unique', async () => {
// Arrange
const newName = 'unique_name';
mockEntityManager.existsBy.mockResolvedValue(false);
mockEntityManager.update.mockResolvedValue({ affected: 1 } as any);
Object.defineProperty(mockEntityManager, 'connection', {
value: {
options: { type: 'postgres' },
},
configurable: true,
});
mockDDLService.renameColumn.mockResolvedValue(undefined);
// Act
const result = await repository.renameColumn(dataTableId, mockColumn, newName);
// Assert
expect(mockEntityManager.existsBy).toHaveBeenCalledWith(DataTableColumn, {
name: newName,
dataTableId,
});
expect(result.name).toBe(newName);
});
});
describe('validateNotSystemColumn', () => {
it('should throw DataTableSystemColumnNameConflictError for system column names', async () => {
// Arrange - system columns: id, createdAt, updatedAt
const systemColumnNames = ['id', 'createdAt', 'updatedAt'];
for (const systemColumnName of systemColumnNames) {
mockEntityManager.existsBy.mockResolvedValue(false);
// Act & Assert
await expect(
repository.renameColumn(dataTableId, mockColumn, systemColumnName),
).rejects.toThrow(DataTableSystemColumnNameConflictError);
await expect(
repository.renameColumn(dataTableId, mockColumn, systemColumnName),
).rejects.toThrow(
`Column name "${systemColumnName}" is reserved as a system column name.`,
);
}
});
it('should throw DataTableSystemColumnNameConflictError for testing column name', async () => {
// Arrange
const testingColumnName = 'dryRunState';
mockEntityManager.existsBy.mockResolvedValue(false);
// Act & Assert
await expect(
repository.renameColumn(dataTableId, mockColumn, testingColumnName),
).rejects.toThrow(DataTableSystemColumnNameConflictError);
await expect(
repository.renameColumn(dataTableId, mockColumn, testingColumnName),
).rejects.toThrow(
`Column name "${testingColumnName}" is reserved as a testing column name.`,
);
});
});
describe('successful rename', () => {
it('should successfully rename column when all validations pass', async () => {
// Arrange
const newName = 'new_valid_name';
mockEntityManager.existsBy.mockResolvedValue(false);
mockEntityManager.update.mockResolvedValue({ affected: 1 } as any);
Object.defineProperty(mockEntityManager, 'connection', {
value: {
options: { type: 'postgres' },
},
configurable: true,
});
mockDDLService.renameColumn.mockResolvedValue(undefined);
// Act
const result = await repository.renameColumn(dataTableId, mockColumn, newName);
// Assert
expect(result).toEqual({
...mockColumn,
name: newName,
});
expect(mockEntityManager.update).toHaveBeenCalledWith(
DataTableColumn,
{ id: mockColumn.id },
{ name: newName },
);
expect(mockDDLService.renameColumn).toHaveBeenCalledWith(
dataTableId,
mockColumn.name,
newName,
'postgres',
mockEntityManager,
);
});
it('should call DDL service with correct database type', async () => {
// Arrange
const newName = 'new_valid_name';
const dbTypes = ['postgres', 'mysql', 'sqlite'] as const;
for (const dbType of dbTypes) {
mockEntityManager.existsBy.mockResolvedValue(false);
mockEntityManager.update.mockResolvedValue({ affected: 1 } as any);
Object.defineProperty(mockEntityManager, 'connection', {
value: {
options: { type: dbType },
},
configurable: true,
});
mockDDLService.renameColumn.mockResolvedValue(undefined);
// Act
await repository.renameColumn(dataTableId, mockColumn, newName);
// Assert
expect(mockDDLService.renameColumn).toHaveBeenCalledWith(
dataTableId,
mockColumn.name,
newName,
dbType,
mockEntityManager,
);
}
});
});
describe('validation order', () => {
it('should validate system column name before checking uniqueness', async () => {
// Arrange
const systemColumnName = 'id';
mockEntityManager.existsBy.mockResolvedValue(false);
// Act & Assert
await expect(
repository.renameColumn(dataTableId, mockColumn, systemColumnName),
).rejects.toThrow(DataTableSystemColumnNameConflictError);
// existsBy should not be called because system column validation happens first
expect(mockEntityManager.existsBy).not.toHaveBeenCalled();
});
it('should check uniqueness after system column validation passes', async () => {
// Arrange
const newName = 'valid_name';
const dataTable = { id: dataTableId, name: 'Test Table' } as DataTable;
mockEntityManager.existsBy.mockResolvedValue(true);
mockEntityManager.findOneBy.mockResolvedValue(dataTable);
// Act & Assert
await expect(repository.renameColumn(dataTableId, mockColumn, newName)).rejects.toThrow(
DataTableColumnNameConflictError,
);
// Both validations should have been called in order
expect(mockEntityManager.existsBy).toHaveBeenCalledWith(DataTableColumn, {
name: newName,
dataTableId,
});
});
});
});
});

View File

@@ -0,0 +1,384 @@
import { testModules } from '@n8n/backend-test-utils';
import type { DataSource, DataSourceOptions, EntityManager } from '@n8n/typeorm';
import { mock } from 'jest-mock-extended';
import { DataTableDDLService } from '../data-table-ddl.service';
import * as sqlUtils from '../utils/sql-utils';
// Mock the sql-utils module
jest.mock('../utils/sql-utils', () => ({
...jest.requireActual('../utils/sql-utils'),
renameColumnQuery: jest.fn(),
toTableName: jest.fn(),
}));
describe('DataTableDDLService', () => {
let ddlService: DataTableDDLService;
let mockDataSource: DataSource;
let mockEntityManager: jest.Mocked<EntityManager>;
beforeAll(async () => {
await testModules.loadModules(['data-table']);
});
beforeEach(() => {
mockEntityManager = mock<EntityManager>({
connection: {
options: { type: 'postgres' },
} as any,
});
// Mock the transaction method to execute the callback immediately
(mockEntityManager.transaction as jest.Mock) = jest.fn(
async (callback: (em: EntityManager) => Promise<any>) => {
return await callback(mockEntityManager);
},
);
// Mock the query method
mockEntityManager.query = jest.fn().mockResolvedValue(undefined);
mockDataSource = mock<DataSource>({
manager: mockEntityManager,
});
ddlService = new DataTableDDLService(mockDataSource);
// Reset all mocks
jest.clearAllMocks();
});
describe('renameColumn', () => {
const dataTableId = 'test-table-id';
const oldColumnName = 'old_column';
const newColumnName = 'new_column';
const tableName = 'n8n_data_table_user_test-table-id';
beforeEach(() => {
(sqlUtils.toTableName as jest.Mock).mockReturnValue(tableName);
});
describe('successful rename', () => {
it('should execute rename column query for PostgreSQL', async () => {
// Arrange
const dbType: DataSourceOptions['type'] = 'postgres';
const expectedQuery =
'ALTER TABLE "n8n_data_table_user_test-table-id" RENAME COLUMN "old_column" TO "new_column"';
(sqlUtils.renameColumnQuery as jest.Mock).mockReturnValue(expectedQuery);
// Act
await ddlService.renameColumn(dataTableId, oldColumnName, newColumnName, dbType);
// Assert
expect(sqlUtils.toTableName).toHaveBeenCalledWith(dataTableId);
expect(sqlUtils.renameColumnQuery).toHaveBeenCalledWith(
tableName,
oldColumnName,
newColumnName,
dbType,
);
expect(mockEntityManager.query).toHaveBeenCalledWith(expectedQuery);
});
it('should execute rename column query for MySQL', async () => {
// Arrange
const dbType: DataSourceOptions['type'] = 'mysql';
const expectedQuery =
'ALTER TABLE `n8n_data_table_user_test-table-id` RENAME COLUMN `old_column` TO `new_column`';
(sqlUtils.renameColumnQuery as jest.Mock).mockReturnValue(expectedQuery);
// Act
await ddlService.renameColumn(dataTableId, oldColumnName, newColumnName, dbType);
// Assert
expect(sqlUtils.renameColumnQuery).toHaveBeenCalledWith(
tableName,
oldColumnName,
newColumnName,
dbType,
);
expect(mockEntityManager.query).toHaveBeenCalledWith(expectedQuery);
});
it('should execute rename column query for SQLite', async () => {
// Arrange
const dbType: DataSourceOptions['type'] = 'sqlite';
const expectedQuery =
'ALTER TABLE "n8n_data_table_user_test-table-id" RENAME COLUMN "old_column" TO "new_column"';
(sqlUtils.renameColumnQuery as jest.Mock).mockReturnValue(expectedQuery);
// Act
await ddlService.renameColumn(dataTableId, oldColumnName, newColumnName, dbType);
// Assert
expect(sqlUtils.renameColumnQuery).toHaveBeenCalledWith(
tableName,
oldColumnName,
newColumnName,
dbType,
);
expect(mockEntityManager.query).toHaveBeenCalledWith(expectedQuery);
});
it('should call methods in correct order', async () => {
// Arrange
const dbType: DataSourceOptions['type'] = 'postgres';
const expectedQuery =
'ALTER TABLE "n8n_data_table_user_test-table-id" RENAME COLUMN "old_column" TO "new_column"';
const callOrder: string[] = [];
(sqlUtils.toTableName as jest.Mock).mockImplementation(() => {
callOrder.push('toTableName');
return tableName;
});
(sqlUtils.renameColumnQuery as jest.Mock).mockImplementation(() => {
callOrder.push('renameColumnQuery');
return expectedQuery;
});
mockEntityManager.query = jest.fn().mockImplementation(async () => {
callOrder.push('query');
return undefined;
});
// Act
await ddlService.renameColumn(dataTableId, oldColumnName, newColumnName, dbType);
// Assert
expect(callOrder).toEqual(['toTableName', 'renameColumnQuery', 'query']);
});
});
describe('with transaction parameter', () => {
it('should use provided transaction manager', async () => {
// Arrange
const dbType: DataSourceOptions['type'] = 'postgres';
const expectedQuery =
'ALTER TABLE "n8n_data_table_user_test-table-id" RENAME COLUMN "old_column" TO "new_column"';
const customTrx = mock<EntityManager>();
customTrx.query = jest.fn().mockResolvedValue(undefined) as any;
(sqlUtils.renameColumnQuery as jest.Mock).mockReturnValue(expectedQuery);
// Act
await ddlService.renameColumn(dataTableId, oldColumnName, newColumnName, dbType, customTrx);
// Assert
expect(sqlUtils.renameColumnQuery).toHaveBeenCalledWith(
tableName,
oldColumnName,
newColumnName,
dbType,
);
expect(customTrx.query).toHaveBeenCalledWith(expectedQuery);
});
it('should execute within transaction when no transaction manager is provided', async () => {
// Arrange
const dbType: DataSourceOptions['type'] = 'postgres';
const expectedQuery =
'ALTER TABLE "n8n_data_table_user_test-table-id" RENAME COLUMN "old_column" TO "new_column"';
(sqlUtils.renameColumnQuery as jest.Mock).mockReturnValue(expectedQuery);
// Act
await ddlService.renameColumn(dataTableId, oldColumnName, newColumnName, dbType);
// Assert
expect(mockEntityManager.transaction).toHaveBeenCalled();
expect(mockEntityManager.query).toHaveBeenCalledWith(expectedQuery);
});
});
describe('error handling', () => {
it('should propagate errors from query execution', async () => {
// Arrange
const dbType: DataSourceOptions['type'] = 'postgres';
const expectedQuery =
'ALTER TABLE "n8n_data_table_user_test-table-id" RENAME COLUMN "old_column" TO "new_column"';
const queryError = new Error('Database query failed');
(sqlUtils.renameColumnQuery as jest.Mock).mockReturnValue(expectedQuery);
mockEntityManager.query = jest.fn().mockRejectedValue(queryError);
// Act & Assert
await expect(
ddlService.renameColumn(dataTableId, oldColumnName, newColumnName, dbType),
).rejects.toThrow(queryError);
expect(mockEntityManager.query).toHaveBeenCalledWith(expectedQuery);
});
it('should propagate errors from renameColumnQuery', async () => {
// Arrange
const dbType: DataSourceOptions['type'] = 'postgres';
const queryError = new Error('Invalid column name');
(sqlUtils.renameColumnQuery as jest.Mock).mockImplementation(() => {
throw queryError;
});
// Act & Assert
await expect(
ddlService.renameColumn(dataTableId, oldColumnName, newColumnName, dbType),
).rejects.toThrow(queryError);
expect(sqlUtils.renameColumnQuery).toHaveBeenCalled();
expect(mockEntityManager.query).not.toHaveBeenCalled();
});
});
describe('parameter handling', () => {
it('should handle special characters in column names', async () => {
// Arrange
const dbType: DataSourceOptions['type'] = 'postgres';
const oldNameWithSpecialChars = 'old_column_2024';
const newNameWithSpecialChars = 'new_column_v2';
const expectedQuery =
'ALTER TABLE "n8n_data_table_user_test-table-id" RENAME COLUMN "old_column_2024" TO "new_column_v2"';
(sqlUtils.renameColumnQuery as jest.Mock).mockReturnValue(expectedQuery);
// Act
await ddlService.renameColumn(
dataTableId,
oldNameWithSpecialChars,
newNameWithSpecialChars,
dbType,
);
// Assert
expect(sqlUtils.renameColumnQuery).toHaveBeenCalledWith(
tableName,
oldNameWithSpecialChars,
newNameWithSpecialChars,
dbType,
);
expect(mockEntityManager.query).toHaveBeenCalledWith(expectedQuery);
});
it('should handle different data table IDs', async () => {
// Arrange
const dbType: DataSourceOptions['type'] = 'postgres';
const differentTableId = 'different-table-id';
const differentTableName = 'n8n_data_table_user_different-table-id';
const expectedQuery =
'ALTER TABLE "n8n_data_table_user_different-table-id" RENAME COLUMN "old_column" TO "new_column"';
(sqlUtils.toTableName as jest.Mock).mockReturnValue(differentTableName);
(sqlUtils.renameColumnQuery as jest.Mock).mockReturnValue(expectedQuery);
// Act
await ddlService.renameColumn(differentTableId, oldColumnName, newColumnName, dbType);
// Assert
expect(sqlUtils.toTableName).toHaveBeenCalledWith(differentTableId);
expect(sqlUtils.renameColumnQuery).toHaveBeenCalledWith(
differentTableName,
oldColumnName,
newColumnName,
dbType,
);
});
});
describe('database type specific behavior', () => {
const testCases: Array<{
dbType: DataSourceOptions['type'];
expectedQuery: string;
}> = [
{
dbType: 'postgres',
expectedQuery:
'ALTER TABLE "n8n_data_table_user_test-table-id" RENAME COLUMN "old_column" TO "new_column"',
},
{
dbType: 'mysql',
expectedQuery:
'ALTER TABLE `n8n_data_table_user_test-table-id` RENAME COLUMN `old_column` TO `new_column`',
},
{
dbType: 'mariadb',
expectedQuery:
'ALTER TABLE `n8n_data_table_user_test-table-id` RENAME COLUMN `old_column` TO `new_column`',
},
{
dbType: 'sqlite',
expectedQuery:
'ALTER TABLE "n8n_data_table_user_test-table-id" RENAME COLUMN "old_column" TO "new_column"',
},
];
testCases.forEach(({ dbType, expectedQuery }) => {
it(`should generate correct query for ${dbType}`, async () => {
// Arrange
(sqlUtils.renameColumnQuery as jest.Mock).mockReturnValue(expectedQuery);
// Act
await ddlService.renameColumn(dataTableId, oldColumnName, newColumnName, dbType);
// Assert
expect(sqlUtils.renameColumnQuery).toHaveBeenCalledWith(
tableName,
oldColumnName,
newColumnName,
dbType,
);
expect(mockEntityManager.query).toHaveBeenCalledWith(expectedQuery);
});
});
});
describe('integration with utilities', () => {
it('should properly convert dataTableId to table name', async () => {
// Arrange
const dbType: DataSourceOptions['type'] = 'postgres';
const customTableId = 'custom-uuid-1234';
const expectedTableName = 'n8n_data_table_user_custom-uuid-1234';
const expectedQuery =
'ALTER TABLE "n8n_data_table_user_custom-uuid-1234" RENAME COLUMN "old_column" TO "new_column"';
(sqlUtils.toTableName as jest.Mock).mockReturnValue(expectedTableName);
(sqlUtils.renameColumnQuery as jest.Mock).mockReturnValue(expectedQuery);
// Act
await ddlService.renameColumn(customTableId, oldColumnName, newColumnName, dbType);
// Assert
expect(sqlUtils.toTableName).toHaveBeenCalledTimes(1);
expect(sqlUtils.toTableName).toHaveBeenCalledWith(customTableId);
expect(sqlUtils.renameColumnQuery).toHaveBeenCalledWith(
expectedTableName,
oldColumnName,
newColumnName,
dbType,
);
});
it('should pass all parameters to renameColumnQuery utility', async () => {
// Arrange
const dbType: DataSourceOptions['type'] = 'mysql';
const expectedQuery = 'ALTER TABLE query';
(sqlUtils.renameColumnQuery as jest.Mock).mockReturnValue(expectedQuery);
// Act
await ddlService.renameColumn(dataTableId, oldColumnName, newColumnName, dbType);
// Assert
expect(sqlUtils.renameColumnQuery).toHaveBeenCalledWith(
tableName,
oldColumnName,
newColumnName,
dbType,
);
expect(sqlUtils.renameColumnQuery).toHaveBeenCalledTimes(1);
});
});
});
});

View File

@@ -0,0 +1,365 @@
import { mockInstance, testModules } from '@n8n/backend-test-utils';
import type { RenameDataTableColumnDto } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { ProjectRelationRepository } from '@n8n/db';
import { CsvParserService } from '../csv-parser.service';
import type { DataTableColumn } from '../data-table-column.entity';
import { DataTableColumnRepository } from '../data-table-column.repository';
import { DataTableFileCleanupService } from '../data-table-file-cleanup.service';
import { DataTableRowsRepository } from '../data-table-rows.repository';
import { DataTableSizeValidator } from '../data-table-size-validator.service';
import type { DataTable } from '../data-table.entity';
import { DataTableRepository } from '../data-table.repository';
import { DataTableService } from '../data-table.service';
import { DataTableColumnNotFoundError } from '../errors/data-table-column-not-found.error';
import { DataTableNotFoundError } from '../errors/data-table-not-found.error';
import { RoleService } from '@/services/role.service';
describe('DataTableService', () => {
let dataTableService: DataTableService;
let mockDataTableRepository: jest.Mocked<DataTableRepository>;
let mockDataTableColumnRepository: jest.Mocked<DataTableColumnRepository>;
let mockDataTableRowsRepository: jest.Mocked<DataTableRowsRepository>;
let mockLogger: jest.Mocked<Logger>;
let mockDataTableSizeValidator: jest.Mocked<DataTableSizeValidator>;
let mockProjectRelationRepository: jest.Mocked<ProjectRelationRepository>;
let mockRoleService: jest.Mocked<RoleService>;
let mockCsvParserService: jest.Mocked<CsvParserService>;
let mockFileCleanupService: jest.Mocked<DataTableFileCleanupService>;
beforeAll(async () => {
await testModules.loadModules(['data-table']);
});
beforeEach(() => {
mockDataTableRepository = mockInstance(DataTableRepository);
mockDataTableColumnRepository = mockInstance(DataTableColumnRepository);
mockDataTableRowsRepository = mockInstance(DataTableRowsRepository);
mockLogger = mockInstance(Logger);
mockDataTableSizeValidator = mockInstance(DataTableSizeValidator);
mockProjectRelationRepository = mockInstance(ProjectRelationRepository);
mockRoleService = mockInstance(RoleService);
mockCsvParserService = mockInstance(CsvParserService);
mockFileCleanupService = mockInstance(DataTableFileCleanupService);
// Mock the logger.scoped method to return the logger itself
mockLogger.scoped = jest.fn().mockReturnValue(mockLogger);
dataTableService = new DataTableService(
mockDataTableRepository,
mockDataTableColumnRepository,
mockDataTableRowsRepository,
mockLogger,
mockDataTableSizeValidator,
mockProjectRelationRepository,
mockRoleService,
mockCsvParserService,
mockFileCleanupService,
);
jest.clearAllMocks();
});
describe('renameColumn', () => {
const projectId = 'test-project-id';
const dataTableId = 'test-data-table-id';
const columnId = 'test-column-id';
const mockDataTable: DataTable = {
id: dataTableId,
name: 'Test Table',
projectId,
} as DataTable;
const mockColumn: DataTableColumn = {
id: columnId,
name: 'old_column_name',
type: 'string',
index: 0,
dataTableId,
} as DataTableColumn;
const renameDto: RenameDataTableColumnDto = {
name: 'new_column_name',
};
describe('successful rename', () => {
it('should rename column when data table and column exist', async () => {
// Arrange
const renamedColumn = { ...mockColumn, name: renameDto.name };
mockDataTableRepository.findOneBy.mockResolvedValue(mockDataTable);
mockDataTableColumnRepository.findOneBy.mockResolvedValue(mockColumn);
mockDataTableColumnRepository.renameColumn.mockResolvedValue(renamedColumn);
// Act
const result = await dataTableService.renameColumn(
dataTableId,
projectId,
columnId,
renameDto,
);
// Assert
expect(result).toEqual(renamedColumn);
expect(mockDataTableRepository.findOneBy).toHaveBeenCalledWith({
id: dataTableId,
project: {
id: projectId,
},
});
expect(mockDataTableColumnRepository.findOneBy).toHaveBeenCalledWith({
id: columnId,
dataTableId,
});
expect(mockDataTableColumnRepository.renameColumn).toHaveBeenCalledWith(
dataTableId,
mockColumn,
renameDto.name,
);
});
it('should call repository methods in correct order', async () => {
// Arrange
const renamedColumn = { ...mockColumn, name: renameDto.name };
const callOrder: string[] = [];
mockDataTableRepository.findOneBy.mockImplementation(async () => {
callOrder.push('validateDataTableExists');
return mockDataTable;
});
mockDataTableColumnRepository.findOneBy.mockImplementation(async () => {
callOrder.push('validateColumnExists');
return mockColumn;
});
mockDataTableColumnRepository.renameColumn.mockImplementation(async () => {
callOrder.push('renameColumn');
return renamedColumn;
});
// Act
await dataTableService.renameColumn(dataTableId, projectId, columnId, renameDto);
// Assert
expect(callOrder).toEqual([
'validateDataTableExists',
'validateColumnExists',
'renameColumn',
]);
});
});
describe('validation errors', () => {
it('should throw DataTableNotFoundError when data table does not exist', async () => {
// Arrange
mockDataTableRepository.findOneBy.mockResolvedValue(null);
// Act & Assert
await expect(
dataTableService.renameColumn(dataTableId, projectId, columnId, renameDto),
).rejects.toThrow(DataTableNotFoundError);
await expect(
dataTableService.renameColumn(dataTableId, projectId, columnId, renameDto),
).rejects.toThrow(`Could not find the data table: '${dataTableId}'`);
// Verify that column validation and rename were not called
expect(mockDataTableColumnRepository.findOneBy).not.toHaveBeenCalled();
expect(mockDataTableColumnRepository.renameColumn).not.toHaveBeenCalled();
});
it('should throw DataTableNotFoundError when data table exists but belongs to different project', async () => {
// Arrange
const differentProjectId = 'different-project-id';
mockDataTableRepository.findOneBy.mockResolvedValue(null);
// Act & Assert
await expect(
dataTableService.renameColumn(dataTableId, differentProjectId, columnId, renameDto),
).rejects.toThrow(DataTableNotFoundError);
// Verify that the repository was called with the correct project filter
expect(mockDataTableRepository.findOneBy).toHaveBeenCalledWith({
id: dataTableId,
project: {
id: differentProjectId,
},
});
});
it('should throw DataTableColumnNotFoundError when column does not exist', async () => {
// Arrange
mockDataTableRepository.findOneBy.mockResolvedValue(mockDataTable);
mockDataTableColumnRepository.findOneBy.mockResolvedValue(null);
// Act & Assert
await expect(
dataTableService.renameColumn(dataTableId, projectId, columnId, renameDto),
).rejects.toThrow(DataTableColumnNotFoundError);
await expect(
dataTableService.renameColumn(dataTableId, projectId, columnId, renameDto),
).rejects.toThrow(
`Could not find the column '${columnId}' in the data table: ${dataTableId}`,
);
// Verify that data table validation was called but rename was not
expect(mockDataTableRepository.findOneBy).toHaveBeenCalled();
expect(mockDataTableColumnRepository.renameColumn).not.toHaveBeenCalled();
});
it('should throw DataTableColumnNotFoundError when column exists but belongs to different data table', async () => {
// Arrange
const differentDataTableId = 'different-table-id';
mockDataTableRepository.findOneBy.mockResolvedValue(mockDataTable);
mockDataTableColumnRepository.findOneBy.mockResolvedValue(null);
// Act & Assert
await expect(
dataTableService.renameColumn(differentDataTableId, projectId, columnId, renameDto),
).rejects.toThrow(DataTableColumnNotFoundError);
// Verify that the repository was called with the correct table filter
expect(mockDataTableColumnRepository.findOneBy).toHaveBeenCalledWith({
id: columnId,
dataTableId: differentDataTableId,
});
});
});
describe('validation order', () => {
it('should validate data table existence before validating column existence', async () => {
// Arrange
mockDataTableRepository.findOneBy.mockResolvedValue(null);
mockDataTableColumnRepository.findOneBy.mockResolvedValue(mockColumn);
// Act & Assert
await expect(
dataTableService.renameColumn(dataTableId, projectId, columnId, renameDto),
).rejects.toThrow(DataTableNotFoundError);
// Column validation should not be called if table validation fails
expect(mockDataTableRepository.findOneBy).toHaveBeenCalled();
expect(mockDataTableColumnRepository.findOneBy).not.toHaveBeenCalled();
});
it('should validate column existence before calling rename', async () => {
// Arrange
mockDataTableRepository.findOneBy.mockResolvedValue(mockDataTable);
mockDataTableColumnRepository.findOneBy.mockResolvedValue(null);
// Act & Assert
await expect(
dataTableService.renameColumn(dataTableId, projectId, columnId, renameDto),
).rejects.toThrow(DataTableColumnNotFoundError);
// Rename should not be called if column validation fails
expect(mockDataTableColumnRepository.renameColumn).not.toHaveBeenCalled();
});
});
describe('error propagation from repository', () => {
it('should propagate errors from dataTableColumnRepository.renameColumn', async () => {
// Arrange
const repositoryError = new Error('Database constraint violation');
mockDataTableRepository.findOneBy.mockResolvedValue(mockDataTable);
mockDataTableColumnRepository.findOneBy.mockResolvedValue(mockColumn);
mockDataTableColumnRepository.renameColumn.mockRejectedValue(repositoryError);
// Act & Assert
await expect(
dataTableService.renameColumn(dataTableId, projectId, columnId, renameDto),
).rejects.toThrow(repositoryError);
// Verify that all validations were performed before the error
expect(mockDataTableRepository.findOneBy).toHaveBeenCalled();
expect(mockDataTableColumnRepository.findOneBy).toHaveBeenCalled();
expect(mockDataTableColumnRepository.renameColumn).toHaveBeenCalled();
});
});
describe('edge cases', () => {
it('should handle empty column name in DTO', async () => {
// Arrange
const emptyNameDto: RenameDataTableColumnDto = { name: '' };
const renamedColumn = { ...mockColumn, name: '' };
mockDataTableRepository.findOneBy.mockResolvedValue(mockDataTable);
mockDataTableColumnRepository.findOneBy.mockResolvedValue(mockColumn);
mockDataTableColumnRepository.renameColumn.mockResolvedValue(renamedColumn);
// Act
const result = await dataTableService.renameColumn(
dataTableId,
projectId,
columnId,
emptyNameDto,
);
// Assert
expect(mockDataTableColumnRepository.renameColumn).toHaveBeenCalledWith(
dataTableId,
mockColumn,
'',
);
expect(result.name).toBe('');
});
it('should handle renaming to same name', async () => {
// Arrange
const sameNameDto: RenameDataTableColumnDto = { name: mockColumn.name };
const renamedColumn = { ...mockColumn, name: mockColumn.name };
mockDataTableRepository.findOneBy.mockResolvedValue(mockDataTable);
mockDataTableColumnRepository.findOneBy.mockResolvedValue(mockColumn);
mockDataTableColumnRepository.renameColumn.mockResolvedValue(renamedColumn);
// Act
const result = await dataTableService.renameColumn(
dataTableId,
projectId,
columnId,
sameNameDto,
);
// Assert
expect(mockDataTableColumnRepository.renameColumn).toHaveBeenCalledWith(
dataTableId,
mockColumn,
mockColumn.name,
);
expect(result.name).toBe(mockColumn.name);
});
it('should handle special characters in new column name', async () => {
// Arrange
const specialCharDto: RenameDataTableColumnDto = { name: 'column_with_special@chars!' };
const renamedColumn = { ...mockColumn, name: specialCharDto.name };
mockDataTableRepository.findOneBy.mockResolvedValue(mockDataTable);
mockDataTableColumnRepository.findOneBy.mockResolvedValue(mockColumn);
mockDataTableColumnRepository.renameColumn.mockResolvedValue(renamedColumn);
// Act
const result = await dataTableService.renameColumn(
dataTableId,
projectId,
columnId,
specialCharDto,
);
// Assert
expect(mockDataTableColumnRepository.renameColumn).toHaveBeenCalledWith(
dataTableId,
mockColumn,
specialCharDto.name,
);
expect(result.name).toBe(specialCharDto.name);
});
});
});
});

View File

@@ -24,6 +24,40 @@ export class DataTableColumnRepository extends Repository<DataTableColumn> {
super(DataTableColumn, dataSource.manager);
}
/**
* Validates that a column name is not reserved as a system column
*/
private validateNotSystemColumn(columnName: string): void {
if (DATA_TABLE_SYSTEM_COLUMNS.includes(columnName)) {
throw new DataTableSystemColumnNameConflictError(columnName);
}
if (columnName === DATA_TABLE_SYSTEM_TESTING_COLUMN) {
throw new DataTableSystemColumnNameConflictError(columnName, 'testing');
}
}
/**
* Validates that a column name is unique within a data table
*/
private async validateUniqueColumnName(
columnName: string,
dataTableId: string,
em: EntityManager,
): Promise<void> {
const existingColumnMatch = await em.existsBy(DataTableColumn, {
name: columnName,
dataTableId,
});
if (existingColumnMatch) {
const dataTable = await em.findOneBy(DataTable, { id: dataTableId });
if (!dataTable) {
throw new UnexpectedError('Data table not found');
}
throw new DataTableColumnNameConflictError(columnName, dataTable.name);
}
}
async getColumns(dataTableId: string, trx?: EntityManager) {
return await withTransaction(
this.manager,
@@ -46,25 +80,8 @@ export class DataTableColumnRepository extends Repository<DataTableColumn> {
async addColumn(dataTableId: string, schema: DataTableCreateColumnSchema, trx?: EntityManager) {
return await withTransaction(this.manager, trx, async (em) => {
if (DATA_TABLE_SYSTEM_COLUMNS.includes(schema.name)) {
throw new DataTableSystemColumnNameConflictError(schema.name);
}
if (schema.name === DATA_TABLE_SYSTEM_TESTING_COLUMN) {
throw new DataTableSystemColumnNameConflictError(schema.name, 'testing');
}
const existingColumnMatch = await em.existsBy(DataTableColumn, {
name: schema.name,
dataTableId,
});
if (existingColumnMatch) {
const dataTable = await em.findOneBy(DataTable, { id: dataTableId });
if (!dataTable) {
throw new UnexpectedError('Data table not found');
}
throw new DataTableColumnNameConflictError(schema.name, dataTable.name);
}
this.validateNotSystemColumn(schema.name);
await this.validateUniqueColumnName(schema.name, dataTableId, em);
if (schema.index === undefined) {
const columns = await this.getColumns(dataTableId, em);
@@ -125,6 +142,32 @@ export class DataTableColumnRepository extends Repository<DataTableColumn> {
});
}
async renameColumn(
dataTableId: string,
column: DataTableColumn,
newName: string,
trx?: EntityManager,
) {
return await withTransaction(this.manager, trx, async (em) => {
this.validateNotSystemColumn(newName);
await this.validateUniqueColumnName(newName, dataTableId, em);
const oldName = column.name;
await em.update(DataTableColumn, { id: column.id }, { name: newName });
await this.ddlService.renameColumn(
dataTableId,
oldName,
newName,
em.connection.options.type,
em,
);
return { ...column, name: newName };
});
}
async shiftColumns(dataTableId: string, lowestIndex: number, delta: -1 | 1, trx?: EntityManager) {
await withTransaction(this.manager, trx, async (em) => {
await em

View File

@@ -4,7 +4,13 @@ import { DataSource, DataSourceOptions, EntityManager } from '@n8n/typeorm';
import { UnexpectedError } from 'n8n-workflow';
import { DataTableColumn } from './data-table-column.entity';
import { addColumnQuery, deleteColumnQuery, toDslColumns, toTableName } from './utils/sql-utils';
import {
addColumnQuery,
deleteColumnQuery,
renameColumnQuery,
toDslColumns,
toTableName,
} from './utils/sql-utils';
/**
* Manages database schema operations for data tables (DDL).
@@ -63,4 +69,18 @@ export class DataTableDDLService {
await em.query(deleteColumnQuery(toTableName(dataTableId), columnName, dbType));
});
}
async renameColumn(
dataTableId: string,
oldColumnName: string,
newColumnName: string,
dbType: DataSourceOptions['type'],
trx?: EntityManager,
) {
await withTransaction(this.dataSource.manager, trx, async (em) => {
await em.query(
renameColumnQuery(toTableName(dataTableId), oldColumnName, newColumnName, dbType),
);
});
}
}

View File

@@ -6,6 +6,7 @@ import {
ListDataTableContentQueryDto,
ListDataTableQueryDto,
MoveDataTableColumnDto,
RenameDataTableColumnDto,
UpdateDataTableDto,
UpdateDataTableRowDto,
UpsertDataTableRowDto,
@@ -26,6 +27,7 @@ import {
import { NextFunction, Response } from 'express';
import { DataTableRowReturn } from 'n8n-workflow';
import { ResponseError } from '@/errors/response-errors/abstract/response.error';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ConflictError } from '@/errors/response-errors/conflict.error';
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
@@ -33,7 +35,6 @@ import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { DataTableService } from './data-table.service';
import { DataTableColumnNameConflictError } from './errors/data-table-column-name-conflict.error';
import { DataTableColumnNotFoundError } from './errors/data-table-column-not-found.error';
import { DataTableNameConflictError } from './errors/data-table-name-conflict.error';
import { DataTableNotFoundError } from './errors/data-table-not-found.error';
import { DataTableSystemColumnNameConflictError } from './errors/data-table-system-column-name-conflict.error';
@@ -47,6 +48,26 @@ export class DataTableController {
private readonly projectService: ProjectService,
) {}
private handleDataTableColumnOperationError(e: unknown): never {
if (
e instanceof DataTableColumnNameConflictError ||
e instanceof DataTableSystemColumnNameConflictError ||
e instanceof DataTableNameConflictError
) {
throw new ConflictError(e.message);
}
if (e instanceof DataTableValidationError) {
throw new BadRequestError(e.message);
}
if (e instanceof ResponseError) {
throw e;
}
if (e instanceof Error) {
throw new InternalServerError(e.message, e);
}
throw e;
}
@Middleware()
async validateProjectExists(
req: AuthenticatedRequest<{ projectId: string }>,
@@ -171,18 +192,7 @@ export class DataTableController {
try {
return await this.dataTableService.addColumn(dataTableId, req.params.projectId, dto);
} catch (e: unknown) {
if (e instanceof DataTableNotFoundError) {
throw new NotFoundError(e.message);
} else if (
e instanceof DataTableColumnNameConflictError ||
e instanceof DataTableSystemColumnNameConflictError
) {
throw new ConflictError(e.message);
} else if (e instanceof Error) {
throw new InternalServerError(e.message, e);
} else {
throw e;
}
this.handleDataTableColumnOperationError(e);
}
}
@@ -197,13 +207,7 @@ export class DataTableController {
try {
return await this.dataTableService.deleteColumn(dataTableId, req.params.projectId, columnId);
} catch (e: unknown) {
if (e instanceof DataTableNotFoundError || e instanceof DataTableColumnNotFoundError) {
throw new NotFoundError(e.message);
} else if (e instanceof Error) {
throw new InternalServerError(e.message, e);
} else {
throw e;
}
this.handleDataTableColumnOperationError(e);
}
}
@@ -224,15 +228,28 @@ export class DataTableController {
dto,
);
} catch (e: unknown) {
if (e instanceof DataTableNotFoundError || e instanceof DataTableColumnNotFoundError) {
throw new NotFoundError(e.message);
} else if (e instanceof DataTableValidationError) {
throw new BadRequestError(e.message);
} else if (e instanceof Error) {
throw new InternalServerError(e.message, e);
} else {
throw e;
}
this.handleDataTableColumnOperationError(e);
}
}
@Patch('/:dataTableId/columns/:columnId/rename')
@ProjectScope('dataTable:update')
async renameColumn(
req: AuthenticatedRequest<{ projectId: string }>,
_res: Response,
@Param('dataTableId') dataTableId: string,
@Param('columnId') columnId: string,
@Body dto: RenameDataTableColumnDto,
) {
try {
return await this.dataTableService.renameColumn(
dataTableId,
req.params.projectId,
columnId,
dto,
);
} catch (e: unknown) {
this.handleDataTableColumnOperationError(e);
}
}

View File

@@ -4,6 +4,7 @@ import type {
DeleteDataTableRowsDto,
ListDataTableContentQueryDto,
MoveDataTableColumnDto,
RenameDataTableColumnDto,
DataTableListOptions,
UpsertDataTableRowDto,
UpdateDataTableDto,
@@ -203,6 +204,18 @@ export class DataTableService {
return true;
}
async renameColumn(
dataTableId: string,
projectId: string,
columnId: string,
dto: RenameDataTableColumnDto,
) {
await this.validateDataTableExists(dataTableId, projectId);
const existingColumn = await this.validateColumnExists(dataTableId, columnId);
return await this.dataTableColumnRepository.renameColumn(dataTableId, existingColumn, dto.name);
}
async getManyAndCount(options: DataTableListOptions) {
return await this.dataTableRepository.getManyAndCount(options);
}

View File

@@ -1,9 +1,7 @@
import { UserError } from 'n8n-workflow';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
export class DataTableColumnNotFoundError extends UserError {
export class DataTableColumnNotFoundError extends NotFoundError {
constructor(dataTableId: string, columnId: string) {
super(`Could not find the column '${columnId}' in the data table: ${dataTableId}`, {
level: 'warning',
});
super(`Could not find the column '${columnId}' in the data table: ${dataTableId}`);
}
}

View File

@@ -1,9 +1,7 @@
import { UserError } from 'n8n-workflow';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
export class DataTableNotFoundError extends UserError {
export class DataTableNotFoundError extends NotFoundError {
constructor(dataTableId: string) {
super(`Could not find the data table: '${dataTableId}'`, {
level: 'warning',
});
super(`Could not find the data table: '${dataTableId}'`);
}
}

View File

@@ -106,6 +106,23 @@ export function deleteColumnQuery(
return `ALTER TABLE ${quotedTableName} DROP COLUMN ${quoteIdentifier(column, dbType)}`;
}
export function renameColumnQuery(
tableName: DataTableUserTableName,
oldColumnName: string,
newColumnName: string,
dbType: DataSourceOptions['type'],
): string {
if (!isValidColumnName(oldColumnName) || !isValidColumnName(newColumnName)) {
throw new UnexpectedError(DATA_TABLE_COLUMN_ERROR_MESSAGE);
}
const quotedTableName = quoteIdentifier(tableName, dbType);
const quotedOldName = quoteIdentifier(oldColumnName, dbType);
const quotedNewName = quoteIdentifier(newColumnName, dbType);
return `ALTER TABLE ${quotedTableName} RENAME COLUMN ${quotedOldName} TO ${quotedNewName}`;
}
export function quoteIdentifier(name: string, dbType: DataSourceOptions['type']): string {
switch (dbType) {
case 'mysql':

View File

@@ -3,6 +3,7 @@ import { OnPubSubEvent } from '@n8n/decorators';
import { Service } from '@n8n/di';
import { InstanceSettings } from 'n8n-core';
import os from 'node:os';
import process from 'node:process';
import { N8N_VERSION } from '@/constants';
import { Push } from '@/push';
@@ -46,9 +47,29 @@ export class WorkerStatusService {
}
private generateStatus(): WorkerStatus {
const constrainedMemory = process.constrainedMemory();
// See https://github.com/nodejs/node/issues/59227 for information about why we cap at MAX_SAFE_INTEGER
// The number 18446744073709552000 does come back when running in a container with no constraints
const isInContainer = constrainedMemory > 0 && constrainedMemory < Number.MAX_SAFE_INTEGER;
return {
senderId: this.instanceSettings.hostId,
runningJobsSummary: this.jobProcessor.getRunningJobsSummary(),
isInContainer,
process: {
memory: {
available: process.availableMemory(),
constraint: process.constrainedMemory(),
...process.memoryUsage(),
},
uptime: process.uptime(),
},
host: {
memory: {
total: os.totalmem(),
free: os.freemem(),
},
},
freeMem: os.freemem(),
totalMem: os.totalmem(),
uptime: process.uptime(),
@@ -73,6 +94,6 @@ export class WorkerStatusService {
if (cpus.length === 0) return 'no CPU info';
return `${cpus.length}x ${cpus[0].model} - speed: ${cpus[0].speed}`;
return `${cpus.length}x ${cpus[0].model}`;
}
}

View File

@@ -44,6 +44,7 @@ const { any } = expect;
const testServer = setupTestServer({
endpointGroups: ['credentials'],
enabledFeatures: ['feat:sharing'],
});
let owner: User;

View File

@@ -1068,6 +1068,7 @@
"workerList.item.jobList.empty": "No current jobs",
"workerList.item.jobListTitle": "Current Jobs",
"workerList.item.netListTitle": "Network Interfaces",
"workerList.item.memoryMonitorTitle": "Memory Monitoring",
"workerList.item.chartsTitle": "Performance Monitoring",
"workerList.item.copyAddressToClipboard": "Address copied to clipboard",
"workerList.actionBox.title": "Available on the Enterprise plan",
@@ -3470,13 +3471,15 @@
"dataTable.addColumn.nameInput.placeholder": "Enter column name",
"dataTable.addColumn.typeInput.label": "@:_reusableBaseText.type",
"dataTable.addColumn.error": "Error adding column",
"dataTable.addColumn.alreadyExistsError": "This column already exists",
"dataTable.column.alreadyExistsError": "This column already exists",
"dataTable.moveColumn.error": "Error moving column",
"dataTable.deleteColumn.error": "Error deleting column",
"dataTable.deleteColumn.confirm.title": "Delete column",
"dataTable.deleteColumn.confirm.message": "Are you sure you want to delete the column '{name}'? This action cannot be undone.",
"dataTable.addColumn.invalidName.error": "Invalid column name",
"dataTable.addColumn.invalidName.description": "Column names must begin with a letter and can only include letters, numbers, or underscores",
"dataTable.renameColumn.label": "Rename column",
"dataTable.renameColumn.error": "Error renaming column",
"dataTable.fetchContent.error": "Error fetching data table content",
"dataTable.addRow.label": "Add Row",
"dataTable.addRow.error": "Error adding row",

View File

@@ -625,6 +625,13 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
onStreamDone,
onStreamError,
);
telemetry.track('User edited chat hub message', {
...flattenModel(model),
is_custom: model.provider === 'custom-agent',
chat_session_id: sessionId,
chat_message_id: editId,
});
}
function regenerateMessage(
@@ -662,6 +669,13 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
onStreamDone,
onStreamError,
);
telemetry.track('User regenerated chat hub message', {
...flattenModel(model),
is_custom: model.provider === 'custom-agent',
chat_session_id: sessionId,
chat_message_id: retryId,
});
}
async function stopStreamingMessage(sessionId: ChatSessionId) {

View File

@@ -8,6 +8,7 @@ import { providerDisplayNames } from '@/features/ai/chatHub/constants';
import CredentialIcon from '@/features/credentials/components/CredentialIcon.vue';
import CredentialPicker from '@/features/credentials/components/CredentialPicker/CredentialPicker.vue';
import { useI18n } from '@n8n/i18n';
import { useTelemetry } from '@/app/composables/useTelemetry';
const props = defineProps<{
modalName: string;
@@ -15,11 +16,12 @@ const props = defineProps<{
provider: ChatHubLLMProvider;
initialValue: string | null;
onSelect: (provider: ChatHubLLMProvider, credentialId: string | null) => void;
onCreateNew: (provider: ChatHubLLMProvider) => void;
};
}>();
const i18n = useI18n();
const telemetry = useTelemetry();
const modalBus = ref(createEventBus());
const selectedCredentialId = ref<string | null>(props.data.initialValue);
@@ -45,6 +47,15 @@ function onDeleteCredential(credentialId: string) {
}
}
function onCredentialModalOpened(credentialId?: string) {
telemetry.track('User opened Credential modal', {
credential_type: credentialType.value,
source: 'chat',
new_credential: !credentialId,
workflow_id: null,
});
}
function onConfirm() {
if (selectedCredentialId.value) {
props.data.onSelect(props.data.provider, selectedCredentialId.value);
@@ -106,6 +117,7 @@ function onCancel() {
@credential-selected="onCredentialSelect"
@credential-deselected="onCredentialDeselect"
@credential-deleted="onDeleteCredential"
@credential-modal-opened="onCredentialModalOpened"
/>
</div>
</div>

View File

@@ -274,7 +274,6 @@ function openCredentialsSelectorOrCreate(provider: ChatHubLLMProvider) {
provider,
initialValue: credentials?.[provider] ?? null,
onSelect: handleSelectCredentials,
onCreateNew: handleCreateNewCredential,
},
});
}
@@ -321,19 +320,6 @@ function onSelect(id: string) {
emit('change', parsedModel);
}
function handleCreateNewCredential(provider: ChatHubLLMProvider) {
const credentialType = PROVIDER_CREDENTIAL_TYPE_MAP[provider];
telemetry.track('User opened Credential modal', {
credential_type: credentialType,
source: 'chat',
new_credential: true,
workflow_id: null,
});
uiStore.openNewCredential(credentialType);
}
onClickOutside(
computed(() => dropdownRef.value?.$el),
() => dropdownRef.value?.close(),

View File

@@ -23,6 +23,7 @@ import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useI18n } from '@n8n/i18n';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { getResourcePermissions } from '@n8n/permissions';
import { useTelemetry } from '@/app/composables/useTelemetry';
const props = defineProps<{
modalName: string;
@@ -38,6 +39,7 @@ const credentialsStore = useCredentialsStore();
const nodeTypesStore = useNodeTypesStore();
const projectStore = useProjectsStore();
const uiStore = useUIStore();
const telemetry = useTelemetry();
const canCreateCredentials = computed(() => {
return getResourcePermissions(projectStore.personalProject?.scopes).credential.create;
@@ -141,6 +143,13 @@ function onCreateNewCredential(providerKey: ChatHubAgentTool) {
const provider = AVAILABLE_TOOLS[providerKey];
if (!provider.credentialType) return;
telemetry.track('User opened Credential modal', {
credential_type: provider.credentialType,
source: 'chat',
new_credential: true,
workflow_id: null,
});
uiStore.openNewCredential(provider.credentialType);
}

View File

@@ -57,7 +57,6 @@ export const ChatModule: FrontendModuleDescription = {
provider: null,
initialValue: null,
onSelect: () => {},
onCreateNew: () => {},
},
},
},

View File

@@ -45,7 +45,7 @@ vi.mock('@n8n/i18n', async (importOriginal) => ({
'dataTable.addColumn.invalidName.description':
'Column names must start with a letter and contain only letters, numbers, and hyphens',
'dataTable.addColumn.error': 'Error adding column',
'dataTable.addColumn.alreadyExistsError': `Column "${options?.interpolate?.name}" already exists`,
'dataTable.column.alreadyExistsError': `Column "${options?.interpolate?.name}" already exists`,
'dataTable.addColumn.systemColumnDescription': 'This is a system column',
'dataTable.addColumn.testingColumnDescription': 'This is a testing column',
'dataTable.addColumn.alreadyExistsDescription': 'Column already exists',

View File

@@ -80,7 +80,7 @@ const onAddButtonClicked = async () => {
let errorDescription = response.errorMessage;
// Provide custom error message for conflict (column already exists)
if (response.httpStatus === 409) {
errorMessage = i18n.baseText('dataTable.addColumn.alreadyExistsError', {
errorMessage = i18n.baseText('dataTable.column.alreadyExistsError', {
interpolate: { name: columnName.value },
});
errorDescription = response.errorMessage?.includes('system')

View File

@@ -80,4 +80,200 @@ describe('ColumnHeader', () => {
await userEvent.click(getByTestId('action-delete'));
expect(onDeleteMock).toHaveBeenCalledWith('col-1');
});
describe('onNameSubmit', () => {
it('should call onRename when valid new name is provided', async () => {
const onRenameMock = vi.fn();
const { container } = renderComponent({
props: {
params: {
displayName: 'Original Name',
column: {
getColId: () => 'col-1',
getColDef: () => ({ cellDataType: 'string' }),
getSort: () => null,
},
onRename: onRenameMock,
onDelete: onDeleteMock,
allowMenuActions: true,
api: {
getFilterModel: vi.fn().mockReturnValue({}),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
},
} as unknown as HeaderParamsWithDelete,
},
});
// Find the actual input element within N8nInlineTextEdit
const input = container.querySelector('input') as HTMLInputElement;
expect(input).toBeTruthy();
await userEvent.clear(input);
await userEvent.type(input, 'New Name{Enter}');
expect(onRenameMock).toHaveBeenCalledWith('col-1', 'New Name');
});
it('should trim whitespace before calling onRename', async () => {
const onRenameMock = vi.fn();
const { container } = renderComponent({
props: {
params: {
displayName: 'Original Name',
column: {
getColId: () => 'col-1',
getColDef: () => ({ cellDataType: 'string' }),
getSort: () => null,
},
onRename: onRenameMock,
onDelete: onDeleteMock,
allowMenuActions: true,
api: {
getFilterModel: vi.fn().mockReturnValue({}),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
},
} as unknown as HeaderParamsWithDelete,
},
});
const input = container.querySelector('input') as HTMLInputElement;
expect(input).toBeTruthy();
await userEvent.clear(input);
await userEvent.type(input, ' Trimmed Name {Enter}');
expect(onRenameMock).toHaveBeenCalledWith('col-1', 'Trimmed Name');
});
it('should not call onRename when name is empty', async () => {
const onRenameMock = vi.fn();
const { container } = renderComponent({
props: {
params: {
displayName: 'Original Name',
column: {
getColId: () => 'col-1',
getColDef: () => ({ cellDataType: 'string' }),
getSort: () => null,
},
onRename: onRenameMock,
onDelete: onDeleteMock,
allowMenuActions: true,
api: {
getFilterModel: vi.fn().mockReturnValue({}),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
},
} as unknown as HeaderParamsWithDelete,
},
});
const input = container.querySelector('input') as HTMLInputElement;
expect(input).toBeTruthy();
await userEvent.clear(input);
await fireEvent.blur(input);
expect(onRenameMock).not.toHaveBeenCalled();
});
it('should not call onRename when name is only whitespace', async () => {
const onRenameMock = vi.fn();
const { container } = renderComponent({
props: {
params: {
displayName: 'Original Name',
column: {
getColId: () => 'col-1',
getColDef: () => ({ cellDataType: 'string' }),
getSort: () => null,
},
onRename: onRenameMock,
onDelete: onDeleteMock,
allowMenuActions: true,
api: {
getFilterModel: vi.fn().mockReturnValue({}),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
},
} as unknown as HeaderParamsWithDelete,
},
});
const input = container.querySelector('input') as HTMLInputElement;
expect(input).toBeTruthy();
await userEvent.clear(input);
await userEvent.type(input, ' ');
await fireEvent.blur(input);
expect(onRenameMock).not.toHaveBeenCalled();
});
it('should not call onRename when name is unchanged', async () => {
const onRenameMock = vi.fn();
const { container } = renderComponent({
props: {
params: {
displayName: 'Original Name',
column: {
getColId: () => 'col-1',
getColDef: () => ({ cellDataType: 'string' }),
getSort: () => null,
},
onRename: onRenameMock,
onDelete: onDeleteMock,
allowMenuActions: true,
api: {
getFilterModel: vi.fn().mockReturnValue({}),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
},
} as unknown as HeaderParamsWithDelete,
},
});
const input = container.querySelector('input') as HTMLInputElement;
expect(input).toBeTruthy();
await userEvent.clear(input);
await userEvent.type(input, 'Original Name');
await fireEvent.blur(input);
expect(onRenameMock).not.toHaveBeenCalled();
});
it('should not call onRename when onRename callback is not provided', async () => {
const { container } = renderComponent({
props: {
params: {
displayName: 'Original Name',
column: {
getColId: () => 'col-1',
getColDef: () => ({ cellDataType: 'string' }),
getSort: () => null,
},
onDelete: onDeleteMock,
allowMenuActions: true,
api: {
getFilterModel: vi.fn().mockReturnValue({}),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
},
} as unknown as HeaderParamsWithDelete,
},
});
const input = container.querySelector('input') as HTMLInputElement;
expect(input).toBeTruthy();
await userEvent.clear(input);
await userEvent.type(input, 'New Name');
// Should not throw an error
await expect(fireEvent.blur(input)).resolves.not.toThrow();
});
});
});

View File

@@ -1,13 +1,15 @@
<script setup lang="ts">
import type { IHeaderParams, SortDirection } from 'ag-grid-community';
import { useDataTableTypes } from '@/features/core/dataTable/composables/useDataTableTypes';
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { ref, computed, onMounted, onUnmounted, useTemplateRef } from 'vue';
import { useI18n } from '@n8n/i18n';
import { isAGGridCellType } from '@/features/core/dataTable/typeGuards';
import { N8nActionDropdown, N8nIcon, N8nIconButton } from '@n8n/design-system';
import { N8nActionDropdown, N8nIcon, N8nIconButton, N8nInlineTextEdit } from '@n8n/design-system';
import { DATA_TABLE_SYSTEM_COLUMNS } from 'n8n-workflow';
export type HeaderParamsWithDelete = IHeaderParams & {
onDelete?: (columnId: string) => void;
onRename?: (columnId: string, newName: string) => void;
allowMenuActions: boolean;
showTypeIcon?: boolean;
};
@@ -19,6 +21,7 @@ const props = defineProps<{
const { getIconForType, mapToDataTableColumnType } = useDataTableTypes();
const i18n = useI18n();
const renameInput = useTemplateRef<InstanceType<typeof N8nInlineTextEdit>>('renameInput');
const isHovered = ref(false);
const isDropdownOpen = ref(false);
const isFilterOpen = ref(false);
@@ -28,13 +31,32 @@ const shouldShowTypeIcon = computed(() => props.params.showTypeIcon !== false);
const isFilterable = computed(() => props.params.column.getColDef().filter !== false);
const enum ItemAction {
Rename = 'rename',
Delete = 'delete',
}
const onNameSubmit = (newName: string) => {
const trimmed = newName.trim();
if (!trimmed || trimmed === props.params.displayName) {
renameInput.value?.forceCancel();
return;
}
props.params.onRename?.(props.params.column.getColId(), trimmed);
};
const onNameToggle = (e?: Event) => {
e?.stopPropagation();
if (renameInput.value?.forceFocus && !isSystemColumn.value) {
renameInput.value.forceFocus();
}
};
const onItemClick = (action: string) => {
const actionEnum = action as ItemAction;
if (actionEnum === ItemAction.Delete) {
props.params.onDelete?.(props.params.column.getColId());
} else if (actionEnum === ItemAction.Rename) {
onNameToggle();
}
};
@@ -87,14 +109,42 @@ const typeIcon = computed(() => {
return getIconForType(mapToDataTableColumnType(cellDataType));
});
const columnActionItems = [
{
const isSystemColumn = computed(() => {
const columnId = props.params.column.getColId();
return DATA_TABLE_SYSTEM_COLUMNS.includes(columnId);
});
// Constants for width calculation
const CHAR_WIDTH_PX = 7; // Average character width
const PADDING_PX = 16; // Padding and cursor space
const MIN_WIDTH_PX = 50; // Minimum width for short names
const MAX_WIDTH_PX = 250; // Maximum width to prevent overflow
const columnWidth = computed(() => {
const textLength = (props.params.displayName || '').length;
const calculatedWidth = textLength * CHAR_WIDTH_PX + PADDING_PX;
return Math.min(Math.max(calculatedWidth, MIN_WIDTH_PX), MAX_WIDTH_PX);
});
const columnActionItems = computed(() => {
const items = [];
items.push({
id: ItemAction.Rename,
label: i18n.baseText('dataTable.renameColumn.label'),
icon: 'pen',
customClass: 'data-table-column-header-action-item',
} as const);
items.push({
id: ItemAction.Delete,
label: i18n.baseText('dataTable.deleteColumn.confirm.title'),
icon: 'trash-2',
customClass: 'data-table-column-header-action-item',
} as const,
];
} as const);
return items;
});
const isSortable = computed(() => {
return props.params.column.getColDef().sortable;
@@ -158,9 +208,22 @@ onUnmounted(() => {
>
<div class="data-table-column-header-icon-wrapper">
<N8nIcon v-if="typeIcon" :icon="typeIcon" />
<span class="ag-header-cell-text" data-test-id="data-table-column-header-text">{{
props.params.displayName
}}</span>
<N8nInlineTextEdit
v-if="!isSystemColumn"
ref="renameInput"
:model-value="props.params.displayName"
:max-width="columnWidth"
:read-only="false"
:disabled="false"
class="ag-header-cell-text"
data-test-id="data-table-column-header-text"
@update:model-value="onNameSubmit"
@click="onNameToggle"
@keydown.stop
/>
<span v-else class="ag-header-cell-text" data-test-id="data-table-column-header-text">
{{ props.params.displayName }}
</span>
<div v-if="showSortIndicator" class="sort-indicator">
<N8nIcon v-if="currentSort === 'asc'" icon="arrow-up" class="sort-icon-active" />
@@ -216,6 +279,15 @@ onUnmounted(() => {
align-items: center;
gap: var(--spacing--2xs);
min-width: 0;
.n8n-icon,
.n8n-inline-text-edit,
.ag-header-cell-text {
display: inline-flex;
align-items: center;
vertical-align: middle;
line-height: 1;
}
}
.data-table-column-header-icon-wrapper .n8n-icon {
@@ -225,6 +297,12 @@ onUnmounted(() => {
.ag-header-cell-text {
@include mixins.utils-ellipsis;
min-width: 0;
// Remove overflow hidden when inline edit is active to show border
&.n8n-inline-text-edit--active,
&:focus-within {
overflow: visible;
}
}
.sort-indicator {

View File

@@ -56,6 +56,7 @@ const agGrid = useAgGrid<DataTableRow>({
const dataTableColumns = useDataTableColumns({
onDeleteColumn: onDeleteColumnFunction,
onRenameColumn: onRenameColumnFunction,
onAddRowClick: onAddRowClickFunction,
onAddColumn: onAddColumnFunction,
isTextEditorOpen: agGrid.isTextEditorOpen,
@@ -112,6 +113,10 @@ async function onDeleteColumnFunction(columnId: string) {
await dataTableOperations.onDeleteColumn(columnId);
}
async function onRenameColumnFunction(columnId: string, columnName: string) {
await dataTableOperations.onRenameColumn(columnId, columnName);
}
async function onAddColumnFunction(column: DataTableColumnCreatePayload) {
return await dataTableOperations.onAddColumn(column);
}

View File

@@ -51,6 +51,7 @@ vi.mock('@/features/core/dataTable/utils/columnUtils', () => ({
describe('useDataTableColumns', () => {
const mockOnDeleteColumn = vi.fn();
const mockOnRenameColumn = vi.fn();
const mockOnAddRowClick = vi.fn();
const mockOnAddColumn = vi.fn();
const isTextEditorOpen = ref(false);
@@ -62,6 +63,7 @@ describe('useDataTableColumns', () => {
const createComposable = () => {
return useDataTableColumns({
onDeleteColumn: mockOnDeleteColumn,
onRenameColumn: mockOnRenameColumn,
onAddRowClick: mockOnAddRowClick,
onAddColumn: mockOnAddColumn,
isTextEditorOpen,

View File

@@ -37,11 +37,13 @@ import { GRID_FILTER_CONFIG } from '@/features/core/dataTable/utils/filterMappin
export const useDataTableColumns = ({
onDeleteColumn,
onRenameColumn,
onAddRowClick,
onAddColumn,
isTextEditorOpen,
}: {
onDeleteColumn: (columnId: string) => void;
onRenameColumn: (columnId: string, columnName: string) => void;
onAddRowClick: () => void;
onAddColumn: (column: DataTableColumnCreatePayload) => Promise<AddColumnResponse>;
isTextEditorOpen: Ref<boolean>;
@@ -63,6 +65,7 @@ export const useDataTableColumns = ({
headerComponent: ColumnHeader,
headerComponentParams: {
onDelete: onDeleteColumn,
onRename: onRenameColumn,
allowMenuActions: true,
},
cellEditorPopup: false,

View File

@@ -73,6 +73,7 @@ describe('useDataTableOperations', () => {
addDataTableColumn: vi.fn(),
deleteDataTableColumn: vi.fn(),
moveDataTableColumn: vi.fn(),
renameDataTableColumn: vi.fn(),
deleteRows: vi.fn(),
insertEmptyRow: vi.fn(),
} as unknown as ReturnType<typeof useDataTableStore>;
@@ -840,6 +841,218 @@ describe('useDataTableOperations', () => {
});
});
describe('onRenameColumn', () => {
it('should return early when column is not found', async () => {
const colDefs = ref([]);
const { onRenameColumn } = useDataTableOperations({ ...params, colDefs });
await onRenameColumn('non-existent-column', 'newName');
expect(dataTableStore.renameDataTableColumn).not.toHaveBeenCalled();
});
it('should return early when column has no field', async () => {
const colDefs = ref([{ colId: 'col1', headerName: 'Name', cellDataType: 'text' }]);
const { onRenameColumn } = useDataTableOperations({ ...params, colDefs });
await onRenameColumn('col1', 'newName');
expect(dataTableStore.renameDataTableColumn).not.toHaveBeenCalled();
});
it('should rename column successfully', async () => {
const renameDataTableColumnMock = vi.fn().mockResolvedValue(undefined);
vi.mocked(useDataTableStore).mockReturnValue({
...dataTableStore,
renameDataTableColumn: renameDataTableColumnMock,
});
const colDefs = ref([
{ colId: 'col1', field: 'oldName', headerName: 'Old Name', cellDataType: 'text' },
]);
const rowData = ref([
{ id: 1, oldName: 'value1' },
{ id: 2, oldName: 'value2' },
]);
const { onRenameColumn } = useDataTableOperations({ ...params, colDefs, rowData });
await onRenameColumn('col1', 'newName');
expect(colDefs.value[0].headerName).toBe('newName');
expect(colDefs.value[0].field).toBe('newName');
expect(rowData.value).toEqual([
{ id: 1, newName: 'value1' },
{ id: 2, newName: 'value2' },
]);
expect(params.setGridData).toHaveBeenCalledWith({
colDefs: colDefs.value,
rowData: rowData.value,
});
expect(renameDataTableColumnMock).toHaveBeenCalledWith('test', 'test', 'col1', 'newName');
expect(telemetryTrackMock).toHaveBeenCalledWith('User renamed data table column', {
column_id: 'col1',
column_type: 'text',
data_table_id: 'test',
});
});
it('should handle when new name equals old field name', async () => {
const renameDataTableColumnMock = vi.fn().mockResolvedValue(undefined);
vi.mocked(useDataTableStore).mockReturnValue({
...dataTableStore,
renameDataTableColumn: renameDataTableColumnMock,
});
const colDefs = ref([
{ colId: 'col1', field: 'sameName', headerName: 'Old Header', cellDataType: 'text' },
]);
const rowData = ref([{ id: 1, sameName: 'value1' }]);
const { onRenameColumn } = useDataTableOperations({ ...params, colDefs, rowData });
await onRenameColumn('col1', 'sameName');
expect(colDefs.value[0].headerName).toBe('sameName');
expect(colDefs.value[0].field).toBe('sameName');
expect(rowData.value).toEqual([{ id: 1, sameName: 'value1' }]);
expect(renameDataTableColumnMock).toHaveBeenCalledWith('test', 'test', 'col1', 'sameName');
});
it('should rollback header name when API call fails', async () => {
const renameError = new Error('Rename failed');
vi.mocked(useDataTableStore).mockReturnValue({
...dataTableStore,
renameDataTableColumn: vi.fn().mockRejectedValue(renameError),
});
const colDefs = ref([
{ colId: 'col1', field: 'oldName', headerName: 'Old Name', cellDataType: 'text' },
]);
const rowData = ref([{ id: 1, oldName: 'value1' }]);
const { onRenameColumn } = useDataTableOperations({ ...params, colDefs, rowData });
await onRenameColumn('col1', 'newName');
expect(colDefs.value[0].headerName).toBe('Old Name');
expect(colDefs.value[0].field).toBe('oldName');
expect(rowData.value).toEqual([{ id: 1, oldName: 'value1' }]);
expect(showErrorMock).toHaveBeenCalledWith(renameError, 'dataTable.renameColumn.error');
expect(params.setGridData).toHaveBeenCalledTimes(2);
});
it('should show specific error message for 409 conflict', async () => {
const responseError = new ResponseError('Column name already exists', {
httpStatusCode: 409,
});
vi.mocked(useDataTableStore).mockReturnValue({
...dataTableStore,
renameDataTableColumn: vi.fn().mockRejectedValue(responseError),
});
const colDefs = ref([
{ colId: 'col1', field: 'oldName', headerName: 'Old Name', cellDataType: 'text' },
]);
const { onRenameColumn } = useDataTableOperations({ ...params, colDefs });
await onRenameColumn('col1', 'existingName');
expect(colDefs.value[0].headerName).toBe('Old Name');
expect(showErrorMock).toHaveBeenCalledWith(
new Error('Column name already exists'),
'dataTable.column.alreadyExistsError',
);
});
it('should handle ResponseError without httpStatusCode', async () => {
const responseError = new ResponseError('Unknown response error');
vi.mocked(useDataTableStore).mockReturnValue({
...dataTableStore,
renameDataTableColumn: vi.fn().mockRejectedValue(responseError),
});
const colDefs = ref([
{ colId: 'col1', field: 'oldName', headerName: 'Old Name', cellDataType: 'text' },
]);
const { onRenameColumn } = useDataTableOperations({ ...params, colDefs });
await onRenameColumn('col1', 'newName');
expect(colDefs.value[0].headerName).toBe('Old Name');
expect(showErrorMock).toHaveBeenCalledWith(responseError, 'dataTable.renameColumn.error');
});
it('should handle regular Error', async () => {
const error = new Error('Regular error message');
vi.mocked(useDataTableStore).mockReturnValue({
...dataTableStore,
renameDataTableColumn: vi.fn().mockRejectedValue(error),
});
const colDefs = ref([
{ colId: 'col1', field: 'oldName', headerName: 'Old Name', cellDataType: 'text' },
]);
const { onRenameColumn } = useDataTableOperations({ ...params, colDefs });
await onRenameColumn('col1', 'newName');
expect(colDefs.value[0].headerName).toBe('Old Name');
expect(showErrorMock).toHaveBeenCalledWith(error, 'dataTable.renameColumn.error');
});
it('should update row data correctly with multiple columns', async () => {
const renameDataTableColumnMock = vi.fn().mockResolvedValue(undefined);
vi.mocked(useDataTableStore).mockReturnValue({
...dataTableStore,
renameDataTableColumn: renameDataTableColumnMock,
});
const colDefs = ref([
{ colId: 'col1', field: 'name', headerName: 'Name', cellDataType: 'text' },
{ colId: 'col2', field: 'age', headerName: 'Age', cellDataType: 'number' },
]);
const rowData = ref([
{ id: 1, name: 'John', age: 30 },
{ id: 2, name: 'Jane', age: 25 },
]);
const { onRenameColumn } = useDataTableOperations({ ...params, colDefs, rowData });
await onRenameColumn('col1', 'fullName');
expect(colDefs.value[0].field).toBe('fullName');
expect(rowData.value).toEqual([
{ id: 1, fullName: 'John', age: 30 },
{ id: 2, fullName: 'Jane', age: 25 },
]);
});
it('should handle empty row data', async () => {
const renameDataTableColumnMock = vi.fn().mockResolvedValue(undefined);
vi.mocked(useDataTableStore).mockReturnValue({
...dataTableStore,
renameDataTableColumn: renameDataTableColumnMock,
});
const colDefs = ref([
{ colId: 'col1', field: 'oldName', headerName: 'Old Name', cellDataType: 'text' },
]);
const rowData = ref([]);
const { onRenameColumn } = useDataTableOperations({ ...params, colDefs, rowData });
await onRenameColumn('col1', 'newName');
expect(colDefs.value[0].field).toBe('newName');
expect(rowData.value).toEqual([]);
expect(renameDataTableColumnMock).toHaveBeenCalledWith('test', 'test', 'col1', 'newName');
});
});
describe('onCellKeyDown', () => {
const createKeyDownEvent = (
key: string,

View File

@@ -87,7 +87,7 @@ export const useDataTableOperations = ({
const telemetry = useTelemetry();
const dataTableTypes = useDataTableTypes();
const getAddColumnError = (error: unknown): { httpStatus: number; message: string } => {
const parseColumnOperationError = (error: unknown): { httpStatus: number; message: string } => {
const DEFAULT_HTTP_STATUS = 500;
const DEFAULT_MESSAGE = i18n.baseText('generic.unknownError');
@@ -151,6 +151,55 @@ export const useDataTableOperations = ({
}
}
async function onRenameColumn(columnId: string, newName: string): Promise<void> {
const columnToRename = colDefs.value.find((col) => col.colId === columnId);
if (!columnToRename) return;
const oldName = columnToRename.headerName;
const oldField = columnToRename.field;
if (!oldField) return;
columnToRename.headerName = newName;
setGridData({ colDefs: colDefs.value });
try {
toggleSave(true);
await dataTableStore.renameDataTableColumn(dataTableId, projectId, columnId, newName);
columnToRename.field = newName;
if (oldField !== newName) {
rowData.value = rowData.value.map((row) => {
const newRow: DataTableRow = { ...row };
newRow[newName] = newRow[oldField];
delete newRow[oldField];
return newRow;
});
}
setGridData({ colDefs: colDefs.value, rowData: rowData.value });
telemetry.track('User renamed data table column', {
column_id: columnId,
column_type: columnToRename.cellDataType,
data_table_id: dataTableId,
});
} catch (error) {
columnToRename.headerName = oldName;
setGridData({ colDefs: colDefs.value });
const errorDetails = parseColumnOperationError(error);
if (errorDetails.httpStatus === 409) {
toast.showError(
new Error(errorDetails.message),
i18n.baseText('dataTable.column.alreadyExistsError'),
);
} else {
toast.showError(error, i18n.baseText('dataTable.renameColumn.error'));
}
} finally {
toggleSave(false);
}
}
async function onAddColumn(column: DataTableColumnCreatePayload): Promise<AddColumnResponse> {
try {
const newColumn = await dataTableStore.addDataTableColumn(dataTableId, projectId, column);
@@ -166,7 +215,7 @@ export const useDataTableOperations = ({
});
return { success: true, httpStatus: 200 };
} catch (error) {
const addColumnError = getAddColumnError(error);
const addColumnError = parseColumnOperationError(error);
return {
success: false,
httpStatus: addColumnError.httpStatus,
@@ -362,6 +411,7 @@ export const useDataTableOperations = ({
return {
onDeleteColumn,
onRenameColumn,
onAddColumn,
onColumnMoved,
onAddRowClick,

View File

@@ -133,6 +133,23 @@ export const moveDataTableColumnApi = async (
);
};
export const renameDataTableColumnApi = async (
context: IRestApiContext,
dataTableId: string,
projectId: string,
columnId: string,
name: string,
) => {
return await makeRestApiRequest<DataTableColumn>(
context,
'PATCH',
`/projects/${projectId}/data-tables/${dataTableId}/columns/${columnId}/rename`,
{
name,
},
);
};
export const getDataTableRowsApi = async (
context: IRestApiContext,
dataTableId: string,

View File

@@ -340,6 +340,256 @@ describe('dataTable.store', () => {
});
});
describe('renameDataTableColumn', () => {
it('should rename column in data table', async () => {
const dataTableId = faker.string.alphanumeric(10);
const columnId = 'col-1';
const projectId = 'p1';
const newName = 'renamedColumn';
dataTableStore.$patch({
dataTables: [
{
id: dataTableId,
name: 'Test Table',
sizeBytes: 0,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
projectId,
columns: [
{ id: columnId, index: 0, name: 'oldName', type: 'string' },
{ id: 'col-2', index: 1, name: 'otherColumn', type: 'number' },
],
},
],
});
vi.spyOn(dataTableApi, 'renameDataTableColumnApi').mockResolvedValue({
id: columnId,
index: 0,
name: newName,
type: 'string',
});
await dataTableStore.renameDataTableColumn(dataTableId, projectId, columnId, newName);
expect(dataTableApi.renameDataTableColumnApi).toHaveBeenCalledWith(
rootStore.restApiContext,
dataTableId,
projectId,
columnId,
newName,
);
const updatedColumn = dataTableStore.dataTables[0].columns.find((c) => c.id === columnId);
expect(updatedColumn?.name).toBe(newName);
});
it('should not update state when data table is not found', async () => {
const dataTableId = 'non-existent-table';
const columnId = 'col-1';
const projectId = 'p1';
const newName = 'newName';
dataTableStore.$patch({
dataTables: [
{
id: 'other-table',
name: 'Other Table',
sizeBytes: 0,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
projectId,
columns: [{ id: 'col-x', index: 0, name: 'column', type: 'string' }],
},
],
});
vi.spyOn(dataTableApi, 'renameDataTableColumnApi').mockResolvedValue({
id: columnId,
index: 0,
name: newName,
type: 'string',
});
await dataTableStore.renameDataTableColumn(dataTableId, projectId, columnId, newName);
expect(dataTableApi.renameDataTableColumnApi).toHaveBeenCalledWith(
rootStore.restApiContext,
dataTableId,
projectId,
columnId,
newName,
);
// Verify other table's columns remain unchanged
expect(dataTableStore.dataTables[0].columns[0].name).toBe('column');
});
it('should not update state when column is not found in table', async () => {
const dataTableId = faker.string.alphanumeric(10);
const columnId = 'non-existent-column';
const projectId = 'p1';
const newName = 'newName';
dataTableStore.$patch({
dataTables: [
{
id: dataTableId,
name: 'Test Table',
sizeBytes: 0,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
projectId,
columns: [
{ id: 'col-1', index: 0, name: 'column1', type: 'string' },
{ id: 'col-2', index: 1, name: 'column2', type: 'number' },
],
},
],
});
vi.spyOn(dataTableApi, 'renameDataTableColumnApi').mockResolvedValue({
id: columnId,
index: 0,
name: newName,
type: 'string',
});
await dataTableStore.renameDataTableColumn(dataTableId, projectId, columnId, newName);
expect(dataTableApi.renameDataTableColumnApi).toHaveBeenCalledWith(
rootStore.restApiContext,
dataTableId,
projectId,
columnId,
newName,
);
// Verify all columns remain unchanged
expect(dataTableStore.dataTables[0].columns[0].name).toBe('column1');
expect(dataTableStore.dataTables[0].columns[1].name).toBe('column2');
});
it('should handle API errors', async () => {
const dataTableId = faker.string.alphanumeric(10);
const columnId = 'col-1';
const projectId = 'p1';
const newName = 'newName';
dataTableStore.$patch({
dataTables: [
{
id: dataTableId,
name: 'Test Table',
sizeBytes: 0,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
projectId,
columns: [{ id: columnId, index: 0, name: 'oldName', type: 'string' }],
},
],
});
const error = new Error('API Error');
vi.spyOn(dataTableApi, 'renameDataTableColumnApi').mockRejectedValue(error);
await expect(
dataTableStore.renameDataTableColumn(dataTableId, projectId, columnId, newName),
).rejects.toThrow('API Error');
// Verify column name remains unchanged after error
expect(dataTableStore.dataTables[0].columns[0].name).toBe('oldName');
});
it('should rename correct column when multiple columns exist', async () => {
const dataTableId = faker.string.alphanumeric(10);
const columnId = 'col-2';
const projectId = 'p1';
const newName = 'renamedMiddleColumn';
dataTableStore.$patch({
dataTables: [
{
id: dataTableId,
name: 'Test Table',
sizeBytes: 0,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
projectId,
columns: [
{ id: 'col-1', index: 0, name: 'firstColumn', type: 'string' },
{ id: 'col-2', index: 1, name: 'secondColumn', type: 'number' },
{ id: 'col-3', index: 2, name: 'thirdColumn', type: 'boolean' },
],
},
],
});
vi.spyOn(dataTableApi, 'renameDataTableColumnApi').mockResolvedValue({
id: columnId,
index: 0,
name: newName,
type: 'string',
});
await dataTableStore.renameDataTableColumn(dataTableId, projectId, columnId, newName);
expect(dataTableApi.renameDataTableColumnApi).toHaveBeenCalledWith(
rootStore.restApiContext,
dataTableId,
projectId,
columnId,
newName,
);
const columns = dataTableStore.dataTables[0].columns;
expect(columns[0].name).toBe('firstColumn');
expect(columns[1].name).toBe(newName);
expect(columns[2].name).toBe('thirdColumn');
});
it('should handle empty columns array', async () => {
const dataTableId = faker.string.alphanumeric(10);
const columnId = 'col-1';
const projectId = 'p1';
const newName = 'newName';
dataTableStore.$patch({
dataTables: [
{
id: dataTableId,
name: 'Test Table',
sizeBytes: 0,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
projectId,
columns: [],
},
],
});
vi.spyOn(dataTableApi, 'renameDataTableColumnApi').mockResolvedValue({
id: columnId,
index: 0,
name: newName,
type: 'string',
});
await dataTableStore.renameDataTableColumn(dataTableId, projectId, columnId, newName);
expect(dataTableApi.renameDataTableColumnApi).toHaveBeenCalledWith(
rootStore.restApiContext,
dataTableId,
projectId,
columnId,
newName,
);
expect(dataTableStore.dataTables[0].columns).toHaveLength(0);
});
});
describe('fetchDataTableContent', () => {
it('should fetch rows with pagination and filters', async () => {
const mockResponse = { count: 100, data: [{ id: 1, name: 'Row 1' }] };

View File

@@ -10,6 +10,7 @@ import {
addDataTableColumnApi,
deleteDataTableColumnApi,
moveDataTableColumnApi,
renameDataTableColumnApi,
getDataTableRowsApi,
insertDataTableRowApi,
updateDataTableRowsApi,
@@ -215,6 +216,30 @@ export const useDataTableStore = defineStore(DATA_TABLE_STORE, () => {
return moved;
};
const renameDataTableColumn = async (
dataTableId: string,
projectId: string,
columnId: string,
newName: string,
): Promise<void> => {
await renameDataTableColumnApi(
rootStore.restApiContext,
dataTableId,
projectId,
columnId,
newName,
);
const index = dataTables.value.findIndex((table) => table.id === dataTableId);
if (index === -1) return;
const table = dataTables.value[index];
const column = table.columns.find((col) => col.id === columnId);
if (column) {
column.name = newName;
}
};
const fetchDataTableContent = async (
dataTableId: string,
projectId: string,
@@ -333,6 +358,7 @@ export const useDataTableStore = defineStore(DATA_TABLE_STORE, () => {
addDataTableColumn,
deleteDataTableColumn,
moveDataTableColumn,
renameDataTableColumn,
fetchDataTableContent,
insertEmptyRow,
updateRow,

View File

@@ -25,7 +25,7 @@ const props = defineProps<{
const emit = defineEmits<{
credentialSelected: [credentialId: string];
credentialDeselected: [];
credentialModalOpened: [];
credentialModalOpened: [credentialId?: string];
credentialDeleted: [credentialId: string];
}>();
@@ -102,13 +102,13 @@ const onCredentialSelected = (credentialId: string) => {
const createNewCredential = () => {
uiStore.openNewCredential(props.credentialType, true);
wasModalOpenedFromHere.value = true;
emit('credentialModalOpened');
emit('credentialModalOpened', undefined);
};
const editCredential = () => {
assert(props.selectedCredentialId);
uiStore.openExistingCredential(props.selectedCredentialId);
wasModalOpenedFromHere.value = true;
emit('credentialModalOpened');
emit('credentialModalOpened', props.selectedCredentialId);
};
const deleteCredential = async () => {
assert(props.selectedCredentialId);

View File

@@ -7,6 +7,7 @@ import { averageWorkerLoadFromLoadsAsString, memAsGb } from '../orchestration.ut
import WorkerJobAccordion from './WorkerJobAccordion.vue';
import WorkerNetAccordion from './WorkerNetAccordion.vue';
import WorkerChartsAccordion from './WorkerChartsAccordion.vue';
import WorkerMemoryMonitorAccordion from './WorkerMemoryMonitorAccordion.vue';
import { sortByProperty } from '@n8n/utils/sort/sortByProperty';
import { useI18n } from '@n8n/i18n';
@@ -69,8 +70,13 @@ onBeforeUnmount(() => {
data-test-id="worker-card-name"
>
Name: {{ worker.senderId }} ({{ worker.hostname }}) <br />
Average Load: {{ averageWorkerLoadFromLoadsAsString(worker.loadAvg ?? [0]) }} | Free Memory:
{{ memAsGb(worker.freeMem).toFixed(2) }}GB / {{ memAsGb(worker.totalMem).toFixed(2) }}GB
Average Load: {{ averageWorkerLoadFromLoadsAsString(worker.loadAvg ?? [0]) }} | Free memory:
{{ memAsGb(worker.process.memory.available) }}GB /
{{
memAsGb(
worker.isInContainer ? worker.process.memory.constraint : worker.host.memory.total,
)
}}GB
{{ stale ? ' (stale)' : '' }}
</N8nHeading>
</template>
@@ -81,9 +87,11 @@ onBeforeUnmount(() => {
ago | n8n-Version: {{ worker.version }} | Architecture: {{ worker.arch }} (
{{ worker.platform }}) | Uptime: {{ upTime(worker.uptime) }}</span
>
<br />
<WorkerJobAccordion :items="worker.runningJobsSummary" />
<WorkerNetAccordion :items="sortedWorkerInterfaces" />
<WorkerChartsAccordion :worker-id="worker.senderId" />
<WorkerMemoryMonitorAccordion :worker="worker" />
</N8nText>
</div>
<template #append>

View File

@@ -5,7 +5,7 @@ import { ref } from 'vue';
import type { ChartData, ChartOptions } from 'chart.js';
import type { ChartComponentRef } from 'vue-chartjs';
import { Chart } from 'vue-chartjs';
import { averageWorkerLoadFromLoads, memAsGb } from '../orchestration.utils';
import { averageWorkerLoadFromLoads } from '../orchestration.utils';
import { useI18n } from '@n8n/i18n';
const props = defineProps<{
@@ -28,7 +28,7 @@ const blankDataSet = (label: string, color: string, prefill: number = 0) => ({
const orchestrationStore = useOrchestrationStore();
const chartRefJobs = ref<ChartComponentRef | undefined>(undefined);
const chartRefCPU = ref<ChartComponentRef | undefined>(undefined);
const chartRefMemory = ref<ChartComponentRef | undefined>(undefined);
const chartRefMemoryUsage = ref<ChartComponentRef | undefined>(undefined);
const optionsBase: () => Partial<ChartOptions<'line'>> = () => ({
responsive: true,
maintainAspectRatio: true,
@@ -49,9 +49,8 @@ const optionsBase: () => Partial<ChartOptions<'line'>> = () => ({
const optionsJobs: Partial<ChartOptions<'line'>> = optionsBase();
const optionsCPU: Partial<ChartOptions<'line'>> = optionsBase();
if (optionsCPU.scales?.y) optionsCPU.scales.y.suggestedMax = 100;
const maxMemory = memAsGb(orchestrationStore.workers[props.workerId]?.totalMem) ?? 1;
const optionsMemory: Partial<ChartOptions<'line'>> = optionsBase();
if (optionsMemory.scales?.y) optionsMemory.scales.y.suggestedMax = maxMemory;
if (optionsMemory.scales?.y) optionsMemory.scales.y.suggestedMax = 100;
// prefilled initial arrays
const dataJobs = ref<ChartData>(
@@ -60,8 +59,8 @@ const dataJobs = ref<ChartData>(
const dataCPU = ref<ChartData>(
blankDataSet('Processor Usage', 'rgb(19, 205, 103)', WORKER_HISTORY_LENGTH),
);
const dataMemory = ref<ChartData>(
blankDataSet('Memory Usage', 'rgb(244, 216, 174)', WORKER_HISTORY_LENGTH),
const dataMemoryUsage = ref<ChartData>(
blankDataSet('Memory Usage (%)', 'rgb(244, 216, 174)', WORKER_HISTORY_LENGTH),
);
orchestrationStore.$onAction(({ name, store }) => {
@@ -74,22 +73,33 @@ orchestrationStore.$onAction(({ name, store }) => {
'rgb(19, 205, 103)',
prefillCount,
);
const newDataMemory: ChartData = blankDataSet(
'Memory Usage',
const newDataMemoryUsage: ChartData = blankDataSet(
'Memory Usage (%)',
'rgb(244, 216, 174)',
prefillCount,
);
store.workersHistory[props.workerId]?.forEach((item) => {
newDataJobs.datasets[0].data.push(item.data.runningJobsSummary.length);
newDataJobs.labels?.push(new Date(item.timestamp).toLocaleTimeString());
newDataCPU.datasets[0].data.push(averageWorkerLoadFromLoads(item.data.loadAvg));
// Map the x axis timestamps to the labels
newDataCPU.labels = newDataJobs.labels;
newDataMemory.datasets[0].data.push(maxMemory - memAsGb(item.data.freeMem));
newDataMemory.labels = newDataJobs.labels;
newDataMemoryUsage.labels = newDataJobs.labels;
const totalMem = item.data.isInContainer
? item.data.process.memory.constraint
: item.data.host.memory.total;
const usedMem = totalMem - item.data.process.memory.available;
const usage = (usedMem / totalMem) * 100;
newDataMemoryUsage.datasets[0].data.push(usage);
});
dataJobs.value = newDataJobs;
dataCPU.value = newDataCPU;
dataMemory.value = newDataMemory;
dataMemoryUsage.value = newDataMemoryUsage;
}
});
</script>
@@ -116,9 +126,9 @@ orchestrationStore.$onAction(({ name, store }) => {
:class="$style.chart"
/>
<Chart
ref="chartRefMemory"
ref="chartRefMemoryUsage"
type="line"
:data="dataMemory"
:data="dataMemoryUsage"
:options="optionsMemory"
:class="$style.chart"
/>

View File

@@ -0,0 +1,81 @@
<script setup lang="ts">
import type { WorkerStatus } from '@n8n/api-types';
import WorkerAccordion from './WorkerAccordion.vue';
import { useI18n } from '@n8n/i18n';
import { memAsGb, memAsMb } from '@/features/settings/orchestration.ee/orchestration.utils';
const props = defineProps<{
worker: WorkerStatus;
}>();
const i18n = useI18n();
</script>
<template>
<WorkerAccordion icon="list-checks" icon-color="text-dark" :initial-expanded="false">
<template #title>
{{ i18n.baseText('workerList.item.memoryMonitorTitle') }}
</template>
<template #content>
<div :class="$style['accordion-content']">
<strong>Host/OS Memory:</strong>
<table>
<tbody>
<tr>
<th>Total (os.totalmem)</th>
<td>{{ memAsGb(props.worker.host.memory.total) }}GB</td>
</tr>
<tr>
<th>Free (os.freemem)</th>
<td>{{ memAsGb(props.worker.host.memory.free) }}GB</td>
</tr>
</tbody>
</table>
<br />
<strong>Process Memory:</strong><br />
<table>
<tbody>
<tr v-if="worker.isInContainer">
<th>Constraint: (process.constrainedMemory)</th>
<td>{{ memAsMb(props.worker.process.memory.constraint) }}MB</td>
</tr>
<tr>
<th>Available: (process.availableMemory)</th>
<td>{{ memAsMb(props.worker.process.memory.available) }}MB</td>
</tr>
<tr>
<th>RSS: (process.memoryUsage().rss)</th>
<td>{{ memAsMb(props.worker.process.memory.rss) }}MB</td>
</tr>
<tr>
<th>Heap total: (process.memoryUsage().heapTotal)</th>
<td>{{ memAsMb(props.worker.process.memory.heapTotal) }}MB</td>
</tr>
<tr>
<th>Heap used: (process.memoryUsage().heapUsed)</th>
<td>{{ memAsMb(props.worker.process.memory.heapUsed) }}MB</td>
</tr>
</tbody>
</table>
</div>
</template>
</WorkerAccordion>
</template>
<style lang="scss" module>
table {
th,
td {
text-align: left;
font-weight: normal;
}
td {
font-variant-numeric: tabular-nums;
padding-left: 8px;
}
}
.accordion-content {
padding: var(--spacing--2xs);
}
</style>

View File

@@ -6,6 +6,10 @@ export function averageWorkerLoadFromLoadsAsString(loads: number[]): string {
return averageWorkerLoadFromLoads(loads).toFixed(2);
}
export function memAsGb(mem: number): number {
return mem / 1024 / 1024 / 1024;
export function memAsGb(mem: number, decimalPlaces: number = 2): number {
return Number((mem / 1024 / 1024 / 1024).toFixed(decimalPlaces));
}
export function memAsMb(mem: number, decimalPlaces: number = 2): number {
return Number((mem / 1024 / 1024).toFixed(decimalPlaces));
}

View File

@@ -88,6 +88,13 @@ const isChatHubEnabled = computed((): boolean => {
return settingsStore.isChatFeatureEnabled;
});
const isChatUsersEnabled = computed((): boolean => {
return (
isChatHubEnabled.value &&
settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.AdvancedPermissions]
);
});
const validateEmails = (value: string | number | boolean | null | undefined) => {
if (typeof value !== 'string') {
return false;
@@ -295,6 +302,7 @@ onMounted(() => {
{
value: ROLE.ChatUser,
label: i18n.baseText('auth.roles.chatUser'),
disabled: !isChatUsersEnabled.value,
},
]
: []),

View File

@@ -140,6 +140,11 @@ const userRoles = computed((): Array<{ value: Role; label: string; disabled?: bo
value: ROLE.Member,
label: i18n.baseText('auth.roles.member'),
},
{
value: ROLE.ChatUser,
label: i18n.baseText('auth.roles.chatUser'),
disabled: !isAdvancedPermissionsEnabled.value,
},
{
value: ROLE.Admin,
label: i18n.baseText('auth.roles.admin'),

View File

@@ -1,15 +1,17 @@
<script lang="ts" setup>
import { computed, onBeforeUnmount, watch } from 'vue';
import { computed, onBeforeUnmount, onMounted, watch } from 'vue';
import { useI18n } from '@n8n/i18n';
import { SETUP_CREDENTIALS_MODAL_KEY } from '@/app/constants';
import { SETUP_CREDENTIALS_MODAL_KEY, TEMPLATE_SETUP_EXPERIENCE } from '@/app/constants';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useUIStore } from '@/app/stores/ui.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { doesNodeHaveAllCredentialsFilled } from '@/app/utils/nodes/nodeTransforms';
import { N8nButton } from '@n8n/design-system';
import { usePostHog } from '@/app/stores/posthog.store';
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
const posthogStore = usePostHog();
const uiStore = useUIStore();
const i18n = useI18n();
@@ -39,6 +41,12 @@ const showButton = computed(() => {
return !allCredentialsFilled.value;
});
const isNewTemplatesSetupEnabled = computed(() => {
return (
posthogStore.getVariant(TEMPLATE_SETUP_EXPERIENCE.name) === TEMPLATE_SETUP_EXPERIENCE.variant
);
});
const unsubscribe = watch(allCredentialsFilled, (newValue) => {
if (newValue) {
workflowsStore.addToWorkflowMetadata({
@@ -49,13 +57,19 @@ const unsubscribe = watch(allCredentialsFilled, (newValue) => {
}
});
const handleClick = () => {
const openSetupModal = () => {
uiStore.openModal(SETUP_CREDENTIALS_MODAL_KEY);
};
onBeforeUnmount(() => {
uiStore.closeModal(SETUP_CREDENTIALS_MODAL_KEY);
});
onMounted(() => {
if (isNewTemplatesSetupEnabled.value && showButton.value) {
openSetupModal();
}
});
</script>
<template>
@@ -66,6 +80,6 @@ onBeforeUnmount(() => {
size="large"
icon="package-open"
type="secondary"
@click="handleClick()"
@click="openSetupModal()"
/>
</template>

View File

@@ -363,7 +363,8 @@ export class DataTableDetails extends BasePage {
const textCount = await textElement.count();
if (textCount > 0 && ariaColIndex) {
const text = await textElement.textContent();
// Use innerText instead of textContent to avoid getting hidden input values
const text = await textElement.innerText();
if (text) {
columnData.push({
index: parseInt(ariaColIndex, 10),

View File

@@ -41,6 +41,12 @@ export class TemplateCredentialSetupPage extends BasePage {
return this.page.getByTestId('setup-workflow-credentials-modal');
}
getSetupCredentialModalCloseButton(): Locator {
return this.page
.getByTestId('setup-workflow-credentials-modal')
.getByRole('button', { name: 'Close this dialog' });
}
getSetupCredentialModalSteps(): Locator {
return this.page
.getByTestId('setup-workflow-credentials-modal')
@@ -68,4 +74,9 @@ export class TemplateCredentialSetupPage extends BasePage {
await messageBox.waitFor({ state: 'visible' });
await messageBox.locator('.btn--cancel').click();
}
async closeSetupCredentialModal(): Promise<void> {
await this.getSetupCredentialModalCloseButton().click();
await this.getCanvasCredentialModal().waitFor({ state: 'hidden' });
}
}

View File

@@ -37,7 +37,8 @@ async function goToWorkflow(n8n: n8nPage, workflowId: string): Promise<void> {
await loadResponsePromise;
}
test.describe('Workflow Actions', () => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip('Workflow Actions', () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});

View File

@@ -1,4 +1,4 @@
import { test, expect } from '../../fixtures/base';
import { test, expect } from '../../../fixtures/base';
test.describe('Admin user', () => {
test('should see same Settings sub menu items as instance owner', async ({ n8n }) => {

View File

@@ -1,4 +1,4 @@
import { test, expect } from '../../fixtures/base';
import { test, expect } from '../../../fixtures/base';
test.describe('Authentication', () => {
const testCases = [

View File

@@ -1,4 +1,4 @@
import { test, expect } from '../../fixtures/base';
import { test, expect } from '../../../fixtures/base';
test.use({ addContainerCapability: { email: true } });

View File

@@ -4,11 +4,14 @@ import { test, expect } from '../../../fixtures/base';
test.describe('Sign In', () => {
test('should login and logout @auth:none', async ({ n8n }) => {
await n8n.goHome();
await n8n.signIn.goToSignIn();
await n8n.signIn.loginWithEmailAndPassword(
INSTANCE_OWNER_CREDENTIALS.email,
INSTANCE_OWNER_CREDENTIALS.password,
);
await expect(n8n.sideBar.getUserMenu()).toBeVisible();
});
});

View File

@@ -73,6 +73,11 @@ test.describe('Template credentials setup @db:reset', () => {
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(3);
await expect(n8n.templateCredentialSetup.getCanvasSetupButton()).toBeVisible();
// Modal should open automatically
await expect(n8n.templateCredentialSetup.getCanvasCredentialModal()).toBeVisible();
// Close the modal and re-open it
await n8n.templateCredentialSetup.closeSetupCredentialModal();
await n8n.templateCredentialSetup.getCanvasSetupButton().click();
await expect(n8n.templateCredentialSetup.getCanvasCredentialModal()).toBeVisible();