mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 02:21:13 +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',
|
'parentFolder',
|
||||||
'workflowCount',
|
'workflowCount',
|
||||||
'subFolderCount',
|
'subFolderCount',
|
||||||
|
'path',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const VALID_SORT_OPTIONS = [
|
const VALID_SORT_OPTIONS = [
|
||||||
|
|||||||
@@ -272,8 +272,12 @@ export const enum StatisticsNames {
|
|||||||
export type AuthProviderType = 'ldap' | 'email' | 'saml'; // | 'google';
|
export type AuthProviderType = 'ldap' | 'email' | 'saml'; // | 'google';
|
||||||
|
|
||||||
export type FolderWithWorkflowAndSubFolderCount = Folder & {
|
export type FolderWithWorkflowAndSubFolderCount = Folder & {
|
||||||
workflowCount: boolean;
|
workflowCount?: boolean;
|
||||||
subFolderCount: number;
|
subFolderCount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FolderWithWorkflowAndSubFolderCountAndPath = FolderWithWorkflowAndSubFolderCount & {
|
||||||
|
path?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TestRunFinalResult = 'success' | 'error' | 'warning';
|
export type TestRunFinalResult = 'success' | 'error' | 'warning';
|
||||||
|
|||||||
@@ -4,29 +4,28 @@ import { DataSource, Repository } from '@n8n/typeorm';
|
|||||||
import { PROJECT_ROOT } from 'n8n-workflow';
|
import { PROJECT_ROOT } from 'n8n-workflow';
|
||||||
|
|
||||||
import { Folder, FolderTagMapping, TagEntity } from '../entities';
|
import { Folder, FolderTagMapping, TagEntity } from '../entities';
|
||||||
import type { FolderWithWorkflowAndSubFolderCount, ListQuery } from '../entities/types-db';
|
import type { FolderWithWorkflowAndSubFolderCountAndPath, ListQuery } from '../entities/types-db';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderCount> {
|
export class FolderRepository extends Repository<Folder> {
|
||||||
constructor(dataSource: DataSource) {
|
constructor(dataSource: DataSource) {
|
||||||
super(Folder, dataSource.manager);
|
super(Folder, dataSource.manager);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getManyAndCount(
|
async getManyAndCount(options: ListQuery.Options = {}) {
|
||||||
options: ListQuery.Options = {},
|
|
||||||
): Promise<[FolderWithWorkflowAndSubFolderCount[], number]> {
|
|
||||||
const query = this.getManyQuery(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);
|
const query = this.getManyQuery(options);
|
||||||
return await query.getMany();
|
return (await query.getMany()) as unknown as FolderWithWorkflowAndSubFolderCountAndPath[];
|
||||||
}
|
}
|
||||||
|
|
||||||
getManyQuery(
|
getManyQuery(options: ListQuery.Options = {}): SelectQueryBuilder<Folder> {
|
||||||
options: ListQuery.Options = {},
|
|
||||||
): SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount> {
|
|
||||||
const query = this.createQueryBuilder('folder');
|
const query = this.createQueryBuilder('folder');
|
||||||
|
|
||||||
this.applySelections(query, options.select, options.filter);
|
this.applySelections(query, options.select, options.filter);
|
||||||
@@ -38,7 +37,7 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
|
|||||||
}
|
}
|
||||||
|
|
||||||
private applySelections(
|
private applySelections(
|
||||||
query: SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount>,
|
query: SelectQueryBuilder<Folder>,
|
||||||
select?: ListQuery.Options['select'],
|
select?: ListQuery.Options['select'],
|
||||||
filter?: ListQuery.Options['filter'],
|
filter?: ListQuery.Options['filter'],
|
||||||
): void {
|
): void {
|
||||||
@@ -50,7 +49,7 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
|
|||||||
}
|
}
|
||||||
|
|
||||||
private applyWorkflowCountSelect(
|
private applyWorkflowCountSelect(
|
||||||
query: SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount>,
|
query: SelectQueryBuilder<Folder>,
|
||||||
filter?: ListQuery.Options['filter'],
|
filter?: ListQuery.Options['filter'],
|
||||||
): void {
|
): void {
|
||||||
if (typeof filter?.isArchived === 'boolean') {
|
if (typeof filter?.isArchived === 'boolean') {
|
||||||
@@ -65,7 +64,7 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
|
|||||||
}
|
}
|
||||||
|
|
||||||
private applyDefaultSelect(
|
private applyDefaultSelect(
|
||||||
query: SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount>,
|
query: SelectQueryBuilder<Folder>,
|
||||||
filter?: ListQuery.Options['filter'],
|
filter?: ListQuery.Options['filter'],
|
||||||
): void {
|
): void {
|
||||||
this.applyWorkflowCountSelect(query, filter);
|
this.applyWorkflowCountSelect(query, filter);
|
||||||
@@ -84,7 +83,7 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
|
|||||||
}
|
}
|
||||||
|
|
||||||
private applyCustomSelect(
|
private applyCustomSelect(
|
||||||
query: SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount>,
|
query: SelectQueryBuilder<Folder>,
|
||||||
select?: ListQuery.Options['select'],
|
select?: ListQuery.Options['select'],
|
||||||
filter?: ListQuery.Options['filter'],
|
filter?: ListQuery.Options['filter'],
|
||||||
): void {
|
): void {
|
||||||
@@ -103,7 +102,7 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
|
|||||||
}
|
}
|
||||||
|
|
||||||
private addRelationFields(
|
private addRelationFields(
|
||||||
query: SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount>,
|
query: SelectQueryBuilder<Folder>,
|
||||||
selections: string[],
|
selections: string[],
|
||||||
select?: ListQuery.Options['select'],
|
select?: ListQuery.Options['select'],
|
||||||
filter?: ListQuery.Options['filter'],
|
filter?: ListQuery.Options['filter'],
|
||||||
@@ -147,7 +146,7 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
|
|||||||
}
|
}
|
||||||
|
|
||||||
private applyFilters(
|
private applyFilters(
|
||||||
query: SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount>,
|
query: SelectQueryBuilder<Folder>,
|
||||||
filter?: ListQuery.Options['filter'],
|
filter?: ListQuery.Options['filter'],
|
||||||
): void {
|
): void {
|
||||||
if (!filter) return;
|
if (!filter) return;
|
||||||
@@ -164,7 +163,7 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
|
|||||||
}
|
}
|
||||||
|
|
||||||
private applyBasicFilters(
|
private applyBasicFilters(
|
||||||
query: SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount>,
|
query: SelectQueryBuilder<Folder>,
|
||||||
filter: ListQuery.Options['filter'],
|
filter: ListQuery.Options['filter'],
|
||||||
): void {
|
): void {
|
||||||
if (filter?.folderIds && Array.isArray(filter.folderIds)) {
|
if (filter?.folderIds && Array.isArray(filter.folderIds)) {
|
||||||
@@ -196,10 +195,7 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyTagsFilter(
|
private applyTagsFilter(query: SelectQueryBuilder<Folder>, tags?: string[]): void {
|
||||||
query: SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount>,
|
|
||||||
tags?: string[],
|
|
||||||
): void {
|
|
||||||
if (!Array.isArray(tags) || tags.length === 0) return;
|
if (!Array.isArray(tags) || tags.length === 0) return;
|
||||||
|
|
||||||
const subQuery = this.createTagsSubQuery(query, tags);
|
const subQuery = this.createTagsSubQuery(query, tags);
|
||||||
@@ -211,7 +207,7 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
|
|||||||
}
|
}
|
||||||
|
|
||||||
private createTagsSubQuery(
|
private createTagsSubQuery(
|
||||||
query: SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount>,
|
query: SelectQueryBuilder<Folder>,
|
||||||
tags: string[],
|
tags: string[],
|
||||||
): SelectQueryBuilder<FolderTagMapping> {
|
): SelectQueryBuilder<FolderTagMapping> {
|
||||||
return query
|
return query
|
||||||
@@ -226,10 +222,7 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private applySorting(
|
private applySorting(query: SelectQueryBuilder<Folder>, sortBy?: string): void {
|
||||||
query: SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount>,
|
|
||||||
sortBy?: string,
|
|
||||||
): void {
|
|
||||||
if (!sortBy) {
|
if (!sortBy) {
|
||||||
query.orderBy('folder.updatedAt', 'DESC');
|
query.orderBy('folder.updatedAt', 'DESC');
|
||||||
return;
|
return;
|
||||||
@@ -245,7 +238,7 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
|
|||||||
}
|
}
|
||||||
|
|
||||||
private applySortingByField(
|
private applySortingByField(
|
||||||
query: SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount>,
|
query: SelectQueryBuilder<Folder>,
|
||||||
field: string,
|
field: string,
|
||||||
direction: 'DESC' | 'ASC',
|
direction: 'DESC' | 'ASC',
|
||||||
): void {
|
): void {
|
||||||
@@ -258,10 +251,7 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyPagination(
|
private applyPagination(query: SelectQueryBuilder<Folder>, options: ListQuery.Options): void {
|
||||||
query: SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount>,
|
|
||||||
options: ListQuery.Options,
|
|
||||||
): void {
|
|
||||||
if (options?.take) {
|
if (options?.take) {
|
||||||
query.skip(options.skip ?? 0).take(options.take);
|
query.skip(options.skip ?? 0).take(options.take);
|
||||||
}
|
}
|
||||||
@@ -320,7 +310,7 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
|
|||||||
}
|
}
|
||||||
|
|
||||||
private applyExcludeFolderFilter(
|
private applyExcludeFolderFilter(
|
||||||
query: SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount>,
|
query: SelectQueryBuilder<Folder>,
|
||||||
excludeFolderIdAndDescendants: string,
|
excludeFolderIdAndDescendants: string,
|
||||||
): void {
|
): void {
|
||||||
// Exclude the specific folder by ID
|
// Exclude the specific folder by ID
|
||||||
@@ -395,4 +385,55 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
|
|||||||
const result = await query.getRawMany<{ id: string }>();
|
const result = await query.getRawMany<{ id: string }>();
|
||||||
return result.map((row) => row.id);
|
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 { PROJECT_ROOT } from 'n8n-workflow';
|
||||||
|
|
||||||
import { FolderRepository } from './folder.repository';
|
import { FolderRepository } from './folder.repository';
|
||||||
import type { Folder } from '../entities';
|
|
||||||
import { WebhookEntity, TagEntity, WorkflowEntity, WorkflowTagMapping } from '../entities';
|
import { WebhookEntity, TagEntity, WorkflowEntity, WorkflowTagMapping } from '../entities';
|
||||||
import type {
|
import type {
|
||||||
ListQueryDb,
|
ListQueryDb,
|
||||||
@@ -348,7 +347,7 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
|
|||||||
baseData: WorkflowFolderUnionRow[],
|
baseData: WorkflowFolderUnionRow[],
|
||||||
extraData: {
|
extraData: {
|
||||||
workflows: ListQueryDb.Workflow.WithSharing[] | ListQueryDb.Workflow.Plain[];
|
workflows: ListQueryDb.Workflow.WithSharing[] | ListQueryDb.Workflow.Plain[];
|
||||||
folders: Folder[];
|
folders: FolderWithWorkflowAndSubFolderCount[];
|
||||||
},
|
},
|
||||||
): WorkflowFolderUnionFull[] {
|
): WorkflowFolderUnionFull[] {
|
||||||
const workflowsMap = new Map(extraData.workflows.map((workflow) => [workflow.id, workflow]));
|
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 { 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 { Folder, FolderTagMappingRepository, FolderRepository, WorkflowRepository } from '@n8n/db';
|
||||||
import { Service } from '@n8n/di';
|
import { Service } from '@n8n/di';
|
||||||
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
|
// 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) {
|
async getManyAndCount(projectId: string, options: ListQuery.Options) {
|
||||||
options.filter = { ...options.filter, projectId, isArchived: false };
|
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();
|
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 () => {
|
test('should combine multiple query parameters correctly', async () => {
|
||||||
const tag = await createTag({ name: 'important' });
|
const tag = await createTag({ name: 'important' });
|
||||||
const parentFolder = await createFolder(ownerProject, { name: 'Parent' });
|
const parentFolder = await createFolder(ownerProject, { name: 'Parent' });
|
||||||
|
|||||||
Reference in New Issue
Block a user