mirror of
https://github.com/n8n-io/n8n.git
synced 2025-12-05 19:27:26 -06:00
Compare commits
13 Commits
28b62d39bf
...
68c8aab6cc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68c8aab6cc | ||
|
|
974ff6615c | ||
|
|
367e7947a3 | ||
|
|
a42e1bde6a | ||
|
|
db7bd59e8c | ||
|
|
480d1e609b | ||
|
|
b22654709a | ||
|
|
8d7f438e1f | ||
|
|
829135ceee | ||
|
|
3f382a0369 | ||
|
|
54ca0c1abc | ||
|
|
e219e7e915 | ||
|
|
6e77f0eb81 |
4
.github/workflows/update-node-popularity.yml
vendored
4
.github/workflows/update-node-popularity.yml
vendored
@@ -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>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Z } from 'zod-class';
|
||||
|
||||
import { dataTableColumnNameSchema } from '../../schemas/data-table.schema';
|
||||
|
||||
export class RenameDataTableColumnDto extends Z.class({
|
||||
name: dataTableColumnNameSchema,
|
||||
}) {}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}'`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ const { any } = expect;
|
||||
|
||||
const testServer = setupTestServer({
|
||||
endpointGroups: ['credentials'],
|
||||
enabledFeatures: ['feat:sharing'],
|
||||
});
|
||||
|
||||
let owner: User;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,6 @@ export const ChatModule: FrontendModuleDescription = {
|
||||
provider: null,
|
||||
initialValue: null,
|
||||
onSelect: () => {},
|
||||
onCreateNew: () => {},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' }] };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 }) => {
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from '../../fixtures/base';
|
||||
import { test, expect } from '../../../fixtures/base';
|
||||
|
||||
test.describe('Authentication', () => {
|
||||
const testCases = [
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from '../../fixtures/base';
|
||||
import { test, expect } from '../../../fixtures/base';
|
||||
|
||||
test.use({ addContainerCapability: { email: true } });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user