mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
feat(core): Update endpoint to retrieve folder to allow returning the path to root (no-changelog) (#15158)
This commit is contained in:
@@ -12,6 +12,7 @@ const VALID_SELECT_FIELDS = [
|
||||
'parentFolder',
|
||||
'workflowCount',
|
||||
'subFolderCount',
|
||||
'path',
|
||||
] as const;
|
||||
|
||||
const VALID_SORT_OPTIONS = [
|
||||
|
||||
@@ -272,8 +272,12 @@ export const enum StatisticsNames {
|
||||
export type AuthProviderType = 'ldap' | 'email' | 'saml'; // | 'google';
|
||||
|
||||
export type FolderWithWorkflowAndSubFolderCount = Folder & {
|
||||
workflowCount: boolean;
|
||||
subFolderCount: number;
|
||||
workflowCount?: boolean;
|
||||
subFolderCount?: number;
|
||||
};
|
||||
|
||||
export type FolderWithWorkflowAndSubFolderCountAndPath = FolderWithWorkflowAndSubFolderCount & {
|
||||
path?: string[];
|
||||
};
|
||||
|
||||
export type TestRunFinalResult = 'success' | 'error' | 'warning';
|
||||
|
||||
@@ -4,29 +4,28 @@ import { DataSource, Repository } from '@n8n/typeorm';
|
||||
import { PROJECT_ROOT } from 'n8n-workflow';
|
||||
|
||||
import { Folder, FolderTagMapping, TagEntity } from '../entities';
|
||||
import type { FolderWithWorkflowAndSubFolderCount, ListQuery } from '../entities/types-db';
|
||||
import type { FolderWithWorkflowAndSubFolderCountAndPath, ListQuery } from '../entities/types-db';
|
||||
|
||||
@Service()
|
||||
export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderCount> {
|
||||
export class FolderRepository extends Repository<Folder> {
|
||||
constructor(dataSource: DataSource) {
|
||||
super(Folder, dataSource.manager);
|
||||
}
|
||||
|
||||
async getManyAndCount(
|
||||
options: ListQuery.Options = {},
|
||||
): Promise<[FolderWithWorkflowAndSubFolderCount[], number]> {
|
||||
async getManyAndCount(options: ListQuery.Options = {}) {
|
||||
const query = this.getManyQuery(options);
|
||||
return await query.getManyAndCount();
|
||||
return (await query.getManyAndCount()) as unknown as [
|
||||
FolderWithWorkflowAndSubFolderCountAndPath[],
|
||||
number,
|
||||
];
|
||||
}
|
||||
|
||||
async getMany(options: ListQuery.Options = {}): Promise<FolderWithWorkflowAndSubFolderCount[]> {
|
||||
async getMany(options: ListQuery.Options = {}) {
|
||||
const query = this.getManyQuery(options);
|
||||
return await query.getMany();
|
||||
return (await query.getMany()) as unknown as FolderWithWorkflowAndSubFolderCountAndPath[];
|
||||
}
|
||||
|
||||
getManyQuery(
|
||||
options: ListQuery.Options = {},
|
||||
): SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount> {
|
||||
getManyQuery(options: ListQuery.Options = {}): SelectQueryBuilder<Folder> {
|
||||
const query = this.createQueryBuilder('folder');
|
||||
|
||||
this.applySelections(query, options.select, options.filter);
|
||||
@@ -38,7 +37,7 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
|
||||
}
|
||||
|
||||
private applySelections(
|
||||
query: SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount>,
|
||||
query: SelectQueryBuilder<Folder>,
|
||||
select?: ListQuery.Options['select'],
|
||||
filter?: ListQuery.Options['filter'],
|
||||
): void {
|
||||
@@ -50,7 +49,7 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
|
||||
}
|
||||
|
||||
private applyWorkflowCountSelect(
|
||||
query: SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount>,
|
||||
query: SelectQueryBuilder<Folder>,
|
||||
filter?: ListQuery.Options['filter'],
|
||||
): void {
|
||||
if (typeof filter?.isArchived === 'boolean') {
|
||||
@@ -65,7 +64,7 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
|
||||
}
|
||||
|
||||
private applyDefaultSelect(
|
||||
query: SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount>,
|
||||
query: SelectQueryBuilder<Folder>,
|
||||
filter?: ListQuery.Options['filter'],
|
||||
): void {
|
||||
this.applyWorkflowCountSelect(query, filter);
|
||||
@@ -84,7 +83,7 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
|
||||
}
|
||||
|
||||
private applyCustomSelect(
|
||||
query: SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount>,
|
||||
query: SelectQueryBuilder<Folder>,
|
||||
select?: ListQuery.Options['select'],
|
||||
filter?: ListQuery.Options['filter'],
|
||||
): void {
|
||||
@@ -103,7 +102,7 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
|
||||
}
|
||||
|
||||
private addRelationFields(
|
||||
query: SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount>,
|
||||
query: SelectQueryBuilder<Folder>,
|
||||
selections: string[],
|
||||
select?: ListQuery.Options['select'],
|
||||
filter?: ListQuery.Options['filter'],
|
||||
@@ -147,7 +146,7 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
|
||||
}
|
||||
|
||||
private applyFilters(
|
||||
query: SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount>,
|
||||
query: SelectQueryBuilder<Folder>,
|
||||
filter?: ListQuery.Options['filter'],
|
||||
): void {
|
||||
if (!filter) return;
|
||||
@@ -164,7 +163,7 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
|
||||
}
|
||||
|
||||
private applyBasicFilters(
|
||||
query: SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount>,
|
||||
query: SelectQueryBuilder<Folder>,
|
||||
filter: ListQuery.Options['filter'],
|
||||
): void {
|
||||
if (filter?.folderIds && Array.isArray(filter.folderIds)) {
|
||||
@@ -196,10 +195,7 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
|
||||
}
|
||||
}
|
||||
|
||||
private applyTagsFilter(
|
||||
query: SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount>,
|
||||
tags?: string[],
|
||||
): void {
|
||||
private applyTagsFilter(query: SelectQueryBuilder<Folder>, tags?: string[]): void {
|
||||
if (!Array.isArray(tags) || tags.length === 0) return;
|
||||
|
||||
const subQuery = this.createTagsSubQuery(query, tags);
|
||||
@@ -211,7 +207,7 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
|
||||
}
|
||||
|
||||
private createTagsSubQuery(
|
||||
query: SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount>,
|
||||
query: SelectQueryBuilder<Folder>,
|
||||
tags: string[],
|
||||
): SelectQueryBuilder<FolderTagMapping> {
|
||||
return query
|
||||
@@ -226,10 +222,7 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
|
||||
});
|
||||
}
|
||||
|
||||
private applySorting(
|
||||
query: SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount>,
|
||||
sortBy?: string,
|
||||
): void {
|
||||
private applySorting(query: SelectQueryBuilder<Folder>, sortBy?: string): void {
|
||||
if (!sortBy) {
|
||||
query.orderBy('folder.updatedAt', 'DESC');
|
||||
return;
|
||||
@@ -245,7 +238,7 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
|
||||
}
|
||||
|
||||
private applySortingByField(
|
||||
query: SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount>,
|
||||
query: SelectQueryBuilder<Folder>,
|
||||
field: string,
|
||||
direction: 'DESC' | 'ASC',
|
||||
): void {
|
||||
@@ -258,10 +251,7 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
|
||||
}
|
||||
}
|
||||
|
||||
private applyPagination(
|
||||
query: SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount>,
|
||||
options: ListQuery.Options,
|
||||
): void {
|
||||
private applyPagination(query: SelectQueryBuilder<Folder>, options: ListQuery.Options): void {
|
||||
if (options?.take) {
|
||||
query.skip(options.skip ?? 0).take(options.take);
|
||||
}
|
||||
@@ -320,7 +310,7 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
|
||||
}
|
||||
|
||||
private applyExcludeFolderFilter(
|
||||
query: SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount>,
|
||||
query: SelectQueryBuilder<Folder>,
|
||||
excludeFolderIdAndDescendants: string,
|
||||
): void {
|
||||
// Exclude the specific folder by ID
|
||||
@@ -395,4 +385,55 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
|
||||
const result = await query.getRawMany<{ id: string }>();
|
||||
return result.map((row) => row.id);
|
||||
}
|
||||
|
||||
async getFolderPathsToRoot(folderIds: string[]): Promise<Map<string, string[]>> {
|
||||
if (!folderIds.length) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
// Base query: select all root folders
|
||||
const baseQuery = this.createQueryBuilder('folder')
|
||||
.select([
|
||||
'folder.id as id',
|
||||
'folder.name as name',
|
||||
'folder.parentFolderId as parentFolderId',
|
||||
'CONCAT(folder.name) as path',
|
||||
'1 as level',
|
||||
])
|
||||
.where('folder.parentFolderId IS NULL');
|
||||
|
||||
const recursiveQuery = this.createQueryBuilder('child')
|
||||
.select([
|
||||
'child.id as id',
|
||||
'child.name as name',
|
||||
'child.parentFolderId as parentFolderId',
|
||||
"CONCAT(parent.path, '/', child.name) as path",
|
||||
'parent.level + 1 as level',
|
||||
])
|
||||
.innerJoin('folder_paths', 'parent', 'child.parentFolderId = parent.id');
|
||||
|
||||
const mainQuery = this.createQueryBuilder()
|
||||
.addCommonTableExpression(
|
||||
`${baseQuery.getQuery()} UNION ALL ${recursiveQuery.getQuery()}`,
|
||||
'folder_paths',
|
||||
{ recursive: true },
|
||||
)
|
||||
.select('fp.id as folder_id, fp.path as folder_path')
|
||||
.from('folder_paths', 'fp')
|
||||
.where('fp.id IN (:...folderIds)', { folderIds });
|
||||
|
||||
const results = await mainQuery.getRawMany<{
|
||||
folder_id: string;
|
||||
folder_path: string;
|
||||
}>();
|
||||
|
||||
const pathMap = new Map<string, string[]>();
|
||||
|
||||
for (const row of results) {
|
||||
const pathNames = row.folder_path.split('/');
|
||||
pathMap.set(row.folder_id, pathNames);
|
||||
}
|
||||
|
||||
return pathMap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import type {
|
||||
import { PROJECT_ROOT } from 'n8n-workflow';
|
||||
|
||||
import { FolderRepository } from './folder.repository';
|
||||
import type { Folder } from '../entities';
|
||||
import { WebhookEntity, TagEntity, WorkflowEntity, WorkflowTagMapping } from '../entities';
|
||||
import type {
|
||||
ListQueryDb,
|
||||
@@ -348,7 +347,7 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
|
||||
baseData: WorkflowFolderUnionRow[],
|
||||
extraData: {
|
||||
workflows: ListQueryDb.Workflow.WithSharing[] | ListQueryDb.Workflow.Plain[];
|
||||
folders: Folder[];
|
||||
folders: FolderWithWorkflowAndSubFolderCount[];
|
||||
},
|
||||
): WorkflowFolderUnionFull[] {
|
||||
const workflowsMap = new Map(extraData.workflows.map((workflow) => [workflow.id, workflow]));
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { CreateFolderDto, DeleteFolderDto, UpdateFolderDto } from '@n8n/api-types';
|
||||
import type { User } from '@n8n/db';
|
||||
import type {
|
||||
FolderWithWorkflowAndSubFolderCount,
|
||||
FolderWithWorkflowAndSubFolderCountAndPath,
|
||||
User,
|
||||
} from '@n8n/db';
|
||||
import { Folder, FolderTagMappingRepository, FolderRepository, WorkflowRepository } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
|
||||
@@ -281,6 +285,27 @@ export class FolderService {
|
||||
|
||||
async getManyAndCount(projectId: string, options: ListQuery.Options) {
|
||||
options.filter = { ...options.filter, projectId, isArchived: false };
|
||||
return await this.folderRepository.getManyAndCount(options);
|
||||
// eslint-disable-next-line prefer-const
|
||||
let [folders, count] = await this.folderRepository.getManyAndCount(options);
|
||||
if (options.select?.path) {
|
||||
folders = await this.enrichFoldersWithPaths(folders);
|
||||
}
|
||||
return [folders, count];
|
||||
}
|
||||
|
||||
private async enrichFoldersWithPaths(
|
||||
folders: FolderWithWorkflowAndSubFolderCount[],
|
||||
): Promise<FolderWithWorkflowAndSubFolderCountAndPath[]> {
|
||||
const folderIds = folders.map((folder) => folder.id);
|
||||
|
||||
const folderPaths = await this.folderRepository.getFolderPathsToRoot(folderIds);
|
||||
|
||||
return folders.map(
|
||||
(folder) =>
|
||||
({
|
||||
...folder,
|
||||
path: folderPaths.get(folder.id),
|
||||
}) as FolderWithWorkflowAndSubFolderCountAndPath,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1275,6 +1275,42 @@ describe('GET /projects/:projectId/folders', () => {
|
||||
expect(response.body.data[0].parentFolder).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should select path field when requested', async () => {
|
||||
const folder1 = await createFolder(ownerProject, { name: 'Test Folder' });
|
||||
const folder2 = await createFolder(ownerProject, {
|
||||
name: 'Test Folder 2',
|
||||
parentFolder: folder1,
|
||||
});
|
||||
const folder3 = await createFolder(ownerProject, {
|
||||
name: 'Test Folder 3',
|
||||
parentFolder: folder2,
|
||||
});
|
||||
|
||||
const response = await authOwnerAgent
|
||||
.get(
|
||||
`/projects/${ownerProject.id}/folders?select=["id","path", "name"]&sortBy=updatedAt:desc`,
|
||||
)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.data[0]).toEqual({
|
||||
id: expect.any(String),
|
||||
name: 'Test Folder 3',
|
||||
path: [folder1.name, folder2.name, folder3.name],
|
||||
});
|
||||
|
||||
expect(response.body.data[1]).toEqual({
|
||||
id: expect.any(String),
|
||||
name: 'Test Folder 2',
|
||||
path: [folder1.name, folder2.name],
|
||||
});
|
||||
|
||||
expect(response.body.data[2]).toEqual({
|
||||
id: expect.any(String),
|
||||
name: 'Test Folder',
|
||||
path: [folder1.name],
|
||||
});
|
||||
});
|
||||
|
||||
test('should combine multiple query parameters correctly', async () => {
|
||||
const tag = await createTag({ name: 'important' });
|
||||
const parentFolder = await createFolder(ownerProject, { name: 'Parent' });
|
||||
|
||||
Reference in New Issue
Block a user