diff --git a/packages/@n8n/api-types/src/dto/folders/__tests__/update-folder.request.dto.test.ts b/packages/@n8n/api-types/src/dto/folders/__tests__/update-folder.request.dto.test.ts index 23213ecbe9..28067c1a25 100644 --- a/packages/@n8n/api-types/src/dto/folders/__tests__/update-folder.request.dto.test.ts +++ b/packages/@n8n/api-types/src/dto/folders/__tests__/update-folder.request.dto.test.ts @@ -4,11 +4,23 @@ describe('UpdateFolderDto', () => { describe('Valid requests', () => { test.each([ { - name: 'name without parentId', + name: 'name', request: { name: 'test', }, }, + { + name: 'tagIds', + request: { + tagIds: ['1', '2'], + }, + }, + { + name: 'empty tagIds', + request: { + tagIds: [], + }, + }, ])('should validate $name', ({ request }) => { const result = UpdateFolderDto.safeParse(request); expect(result.success).toBe(true); @@ -17,11 +29,6 @@ describe('UpdateFolderDto', () => { describe('Invalid requests', () => { test.each([ - { - name: 'missing name', - request: {}, - expectedErrorPath: ['name'], - }, { name: 'empty name', request: { @@ -29,13 +36,27 @@ describe('UpdateFolderDto', () => { }, expectedErrorPath: ['name'], }, + { + name: 'non string tagIds', + request: { + tagIds: [0], + }, + expectedErrorPath: ['tagIds'], + }, + { + name: 'non array tagIds', + request: { + tagIds: 0, + }, + expectedErrorPath: ['tagIds'], + }, ])('should fail validation for $name', ({ request, expectedErrorPath }) => { const result = UpdateFolderDto.safeParse(request); expect(result.success).toBe(false); if (expectedErrorPath) { - expect(result.error?.issues[0].path).toEqual(expectedErrorPath); + expect(result.error?.issues[0].path[0]).toEqual(expectedErrorPath[0]); } }); }); diff --git a/packages/@n8n/api-types/src/dto/folders/update-folder.dto.ts b/packages/@n8n/api-types/src/dto/folders/update-folder.dto.ts index 13d6c03203..f002f6aa00 100644 --- a/packages/@n8n/api-types/src/dto/folders/update-folder.dto.ts +++ b/packages/@n8n/api-types/src/dto/folders/update-folder.dto.ts @@ -1,7 +1,8 @@ +import { z } from 'zod'; import { Z } from 'zod-class'; import { folderNameSchema } from '../../schemas/folder.schema'; - export class UpdateFolderDto extends Z.class({ - name: folderNameSchema, + name: folderNameSchema.optional(), + tagIds: z.array(z.string().max(24)).optional(), }) {} diff --git a/packages/cli/src/databases/repositories/folder-tag-mapping.repository.ts b/packages/cli/src/databases/repositories/folder-tag-mapping.repository.ts new file mode 100644 index 0000000000..360ea93362 --- /dev/null +++ b/packages/cli/src/databases/repositories/folder-tag-mapping.repository.ts @@ -0,0 +1,21 @@ +import { Service } from '@n8n/di'; +import { DataSource, Repository } from '@n8n/typeorm'; + +import { FolderTagMapping } from '../entities/folder-tag-mapping'; + +@Service() +export class FolderTagMappingRepository extends Repository { + constructor(dataSource: DataSource) { + super(FolderTagMapping, dataSource.manager); + } + + async overwriteTags(folderId: string, tagIds: string[]) { + return await this.manager.transaction(async (tx) => { + await tx.delete(FolderTagMapping, { folderId }); + + const tags = tagIds.map((tagId) => this.create({ folderId, tagId })); + + return await tx.insert(FolderTagMapping, tags); + }); + } +} diff --git a/packages/cli/src/services/folder.service.ts b/packages/cli/src/services/folder.service.ts index 0b135360a9..f3caacdd92 100644 --- a/packages/cli/src/services/folder.service.ts +++ b/packages/cli/src/services/folder.service.ts @@ -3,6 +3,7 @@ import { Service } from '@n8n/di'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import type { EntityManager } from '@n8n/typeorm'; +import { FolderTagMappingRepository } from '@/databases/repositories/folder-tag-mapping.repository'; import { FolderRepository } from '@/databases/repositories/folder.repository'; import { FolderNotFoundError } from '@/errors/folder-not-found.error'; @@ -20,7 +21,10 @@ interface FolderPathRow { @Service() export class FolderService { - constructor(private readonly folderRepository: FolderRepository) {} + constructor( + private readonly folderRepository: FolderRepository, + private readonly folderTagMappingRepository: FolderTagMappingRepository, + ) {} async createFolder({ parentFolderId, name }: CreateFolderDto, projectId: string) { let parentFolder = null; @@ -39,9 +43,14 @@ export class FolderService { return folder; } - async updateFolder(folderId: string, projectId: string, { name }: UpdateFolderDto) { + async updateFolder(folderId: string, projectId: string, { name, tagIds }: UpdateFolderDto) { await this.getFolderInProject(folderId, projectId); - return await this.folderRepository.update({ id: folderId }, { name }); + if (name) { + await this.folderRepository.update({ id: folderId }, { name }); + } + if (tagIds) { + await this.folderTagMappingRepository.overwriteTags(folderId, tagIds); + } } async getFolderInProject(folderId: string, projectId: string, em?: EntityManager) { diff --git a/packages/cli/test/integration/folder/folder.controller.test.ts b/packages/cli/test/integration/folder/folder.controller.test.ts index bd7955d51f..8f0454ac38 100644 --- a/packages/cli/test/integration/folder/folder.controller.test.ts +++ b/packages/cli/test/integration/folder/folder.controller.test.ts @@ -4,6 +4,7 @@ import type { User } from '@/databases/entities/user'; import { FolderRepository } from '@/databases/repositories/folder.repository'; import { ProjectRepository } from '@/databases/repositories/project.repository'; import { createFolder } from '@test-integration/db/folders'; +import { createTag } from '@test-integration/db/tags'; import { createTeamProject, linkUserToProject } from '../shared/db/projects'; import { createOwner, createMember } from '../shared/db/users'; @@ -408,4 +409,57 @@ describe('PATCH /projects/:projectId/folders/:folderId', () => { const folderInDb = await folderRepository.findOneBy({ id: folder.id }); expect(folderInDb?.name).toBe('Updated Folder Name'); }); + + test('should update folder tags', async () => { + const project = await createTeamProject('test project', owner); + const folder = await createFolder(project, { name: 'Test Folder' }); + const tag1 = await createTag({ name: 'Tag 1' }); + const tag2 = await createTag({ name: 'Tag 2' }); + + const payload = { + tagIds: [tag1.id, tag2.id], + }; + + await authOwnerAgent + .patch(`/projects/${project.id}/folders/${folder.id}`) + .send(payload) + .expect(200); + + const folderWithTags = await folderRepository.findOne({ + where: { id: folder.id }, + relations: ['tags'], + }); + + expect(folderWithTags?.tags).toHaveLength(2); + expect(folderWithTags?.tags.map((t) => t.id).sort()).toEqual([tag1.id, tag2.id].sort()); + }); + + test('should replace existing folder tags with new ones', async () => { + const project = await createTeamProject(undefined, owner); + const tag1 = await createTag({ name: 'Tag 1' }); + const tag2 = await createTag({ name: 'Tag 2' }); + const tag3 = await createTag({ name: 'Tag 3' }); + + const folder = await createFolder(project, { + name: 'Test Folder', + tags: [tag1, tag2], + }); + + const payload = { + tagIds: [tag3.id], + }; + + await authOwnerAgent + .patch(`/projects/${project.id}/folders/${folder.id}`) + .send(payload) + .expect(200); + + const folderWithTags = await folderRepository.findOne({ + where: { id: folder.id }, + relations: ['tags'], + }); + + expect(folderWithTags?.tags).toHaveLength(1); + expect(folderWithTags?.tags[0].id).toBe(tag3.id); + }); });