feat(core): Update POST /workflows to link folder (no-changelog) (#13449)

This commit is contained in:
Ricardo Espinoza 2025-02-25 15:29:35 -05:00 committed by GitHub
parent 2f395fe89a
commit 461e39a74e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 148 additions and 12 deletions

View file

@ -1,5 +1,5 @@
import { Service } from '@n8n/di';
import type { SelectQueryBuilder } from '@n8n/typeorm';
import type { EntityManager, SelectQueryBuilder } from '@n8n/typeorm';
import { DataSource, Repository } from '@n8n/typeorm';
import type { ListQuery } from '@/requests';
@ -227,8 +227,13 @@ export class FolderRepository extends Repository<FolderWithWorkflowsCount> {
}
}
async findOneOrFailFolderInProject(folderId: string, projectId: string): Promise<Folder> {
return await this.manager.findOneOrFail(Folder, {
async findOneOrFailFolderInProject(
folderId: string,
projectId: string,
em?: EntityManager,
): Promise<Folder> {
const manager = em ?? this.manager;
return await manager.findOneOrFail(Folder, {
where: {
id: folderId,
homeProject: {

View file

@ -112,7 +112,7 @@ export class SharedWorkflowRepository extends Repository<SharedWorkflow> {
workflowId: string,
user: User,
scopes: Scope[],
{ includeTags = false, em = this.manager } = {},
{ includeTags = false, includeParentFolder = false, em = this.manager } = {},
) {
let where: FindOptionsWhere<SharedWorkflow> = { workflowId };
@ -138,6 +138,7 @@ export class SharedWorkflowRepository extends Repository<SharedWorkflow> {
workflow: {
shared: { project: { projectRelations: { user: true } } },
tags: includeTags,
parentFolder: includeParentFolder,
},
},
});

View file

@ -1,5 +1,7 @@
import type { CreateFolderDto } from '@n8n/api-types';
import { Service } from '@n8n/di';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import type { EntityManager } from '@n8n/typeorm';
import { FolderRepository } from '@/databases/repositories/folder.repository';
import { FolderNotFoundError } from '@/errors/folder-not-found.error';
@ -37,9 +39,9 @@ export class FolderService {
return folder;
}
async getFolderInProject(folderId: string, projectId: string) {
async getFolderInProject(folderId: string, projectId: string, em?: EntityManager) {
try {
return await this.folderRepository.findOneOrFailFolderInProject(folderId, projectId);
return await this.folderRepository.findOneOrFailFolderInProject(folderId, projectId, em);
} catch {
throw new FolderNotFoundError(folderId);
}
@ -48,6 +50,10 @@ export class FolderService {
async getFolderTree(folderId: string, projectId: string): Promise<SimpleFolderNode[]> {
await this.getFolderInProject(folderId, projectId);
const escapedParentFolderId = this.folderRepository
.createQueryBuilder()
.escape('parentFolderId');
const baseQuery = this.folderRepository
.createQueryBuilder('folder')
.select('folder.id', 'id')
@ -58,7 +64,7 @@ export class FolderService {
.createQueryBuilder('f')
.select('f.id', 'id')
.addSelect('f.parentFolderId', 'parentFolderId')
.innerJoin('folder_path', 'fp', 'f.id = fp.parentFolderId');
.innerJoin('folder_path', 'fp', `f.id = fp.${escapedParentFolderId}`);
const mainQuery = this.folderRepository
.createQueryBuilder('folder')

View file

@ -22,6 +22,7 @@ export declare namespace WorkflowRequest {
hash: string;
meta: Record<string, unknown>;
projectId: string;
parentFolderId?: string;
}>;
type ManualRunPayload = {

View file

@ -46,6 +46,7 @@ import { License } from '@/license';
import { listQueryMiddleware } from '@/middlewares';
import { AuthenticatedRequest } from '@/requests';
import * as ResponseHelper from '@/response-helper';
import { FolderService } from '@/services/folder.service';
import { NamingService } from '@/services/naming.service';
import { ProjectService } from '@/services/project.service.ee';
import { TagService } from '@/services/tag.service';
@ -82,6 +83,7 @@ export class WorkflowsController {
private readonly projectRelationRepository: ProjectRelationRepository,
private readonly eventService: EventService,
private readonly globalConfig: GlobalConfig,
private readonly folderService: FolderService,
) {}
@Post('/')
@ -133,7 +135,7 @@ export class WorkflowsController {
const savedWorkflow = await Db.transaction(async (transactionManager) => {
const workflow = await transactionManager.save<WorkflowEntity>(newWorkflow);
const { projectId } = req.body;
const { projectId, parentFolderId } = req.body;
project =
projectId === undefined
? await this.projectRepository.getPersonalProjectForUser(req.user.id, transactionManager)
@ -155,6 +157,17 @@ export class WorkflowsController {
throw new ApplicationError('No personal project found');
}
if (parentFolderId) {
try {
const parentFolder = await this.folderService.getFolderInProject(
parentFolderId,
project.id,
transactionManager,
);
await transactionManager.update(WorkflowEntity, { id: workflow.id }, { parentFolder });
} catch {}
}
const newSharedWorkflow = this.sharedWorkflowRepository.create({
role: 'workflow:owner',
projectId: project.id,
@ -167,7 +180,7 @@ export class WorkflowsController {
workflow.id,
req.user,
['workflow:read'],
{ em: transactionManager, includeTags: true },
{ em: transactionManager, includeTags: true, includeParentFolder: true },
);
});

View file

@ -58,13 +58,14 @@ let projectService: ProjectService;
beforeEach(async () => {
await testDb.truncate([
'Workflow',
'SharedWorkflow',
'Tag',
'WorkflowHistory',
'Project',
'ProjectRelation',
'Folder',
'Workflow',
'Tag',
'Project',
'User',
]);
projectRepository = Container.get(ProjectRepository);
projectService = Container.get(ProjectService);
@ -378,6 +379,115 @@ describe('POST /workflows', () => {
message: "You don't have the permissions to save the workflow in this project.",
});
});
test('create link workflow with folder if one is provided', async () => {
//
// ARRANGE
//
const personalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id);
const folder = await createFolder(personalProject, { name: 'Folder 1' });
const workflow = makeWorkflow();
//
// ACT
//
const response = await authOwnerAgent
.post('/workflows')
.send({ ...workflow, parentFolderId: folder.id });
//
// ASSERT
//
expect(response.body.data).toMatchObject({
active: false,
id: expect.any(String),
name: workflow.name,
sharedWithProjects: [],
usedCredentials: [],
homeProject: {
id: personalProject.id,
name: personalProject.name,
type: personalProject.type,
},
parentFolder: {
id: folder.id,
name: folder.name,
},
});
expect(response.body.data.shared).toBeUndefined();
});
test('create workflow without parent folder if no folder is provided', async () => {
//
// ARRANGE
//
const personalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id);
const workflow = makeWorkflow();
//
// ACT
//
const response = await authOwnerAgent
.post('/workflows')
.send({ ...workflow })
.expect(200);
//
// ASSERT
//
expect(response.body.data).toMatchObject({
active: false,
id: expect.any(String),
name: workflow.name,
sharedWithProjects: [],
usedCredentials: [],
homeProject: {
id: personalProject.id,
name: personalProject.name,
type: personalProject.type,
},
parentFolder: null,
});
expect(response.body.data.shared).toBeUndefined();
});
test('create workflow without parent is provided folder does not exist in the project', async () => {
//
// ARRANGE
//
const personalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id);
const workflow = makeWorkflow();
//
// ACT
//
const response = await authOwnerAgent
.post('/workflows')
.send({ ...workflow, parentFolderId: 'non-existing-folder-id' })
.expect(200);
//
// ASSERT
//
expect(response.body.data).toMatchObject({
active: false,
id: expect.any(String),
name: workflow.name,
sharedWithProjects: [],
usedCredentials: [],
homeProject: {
id: personalProject.id,
name: personalProject.name,
type: personalProject.type,
},
parentFolder: null,
});
expect(response.body.data.shared).toBeUndefined();
});
});
describe('GET /workflows/:workflowId', () => {