feat(core): Introduce new query string parameter includeFolders to GET /workflows endpoint (no-changelog) (#13352)

This commit is contained in:
Ricardo Espinoza 2025-02-21 13:54:15 -05:00 committed by GitHub
parent 243042f217
commit c3f111275b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1171 additions and 101 deletions

View file

@ -13,6 +13,10 @@ import { Project } from './project';
import { TagEntity } from './tag-entity';
import { type WorkflowEntity } from './workflow-entity';
export type FolderWithWorkflowsCount = Folder & {
workflowsCount: boolean;
};
@Entity()
export class Folder extends WithTimestampsAndStringId {
@Column()
@ -24,7 +28,7 @@ export class Folder extends WithTimestampsAndStringId {
@ManyToOne(() => Project)
@JoinColumn({ name: 'projectId' })
project: Project;
homeProject: Project;
@OneToMany('WorkflowEntity', 'parentFolder')
workflows: WorkflowEntity[];

View file

@ -4,7 +4,6 @@ import { DateTime } from 'luxon';
import type { Folder } from '@/databases/entities/folder';
import type { Project } from '@/databases/entities/project';
import type { User } from '@/databases/entities/user';
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
import { createFolder } from '@test-integration/db/folders';
import { getPersonalProject } from '@test-integration/db/projects';
import { createTag } from '@test-integration/db/tags';
@ -46,7 +45,7 @@ describe('FolderRepository', () => {
await Promise.all([folder1, folder2]);
const [folders, count] = await folderRepository.getMany();
const [folders, count] = await folderRepository.getManyAndCount();
expect(count).toBe(2);
expect(folders).toHaveLength(2);
@ -60,7 +59,7 @@ describe('FolderRepository', () => {
createdAt: expect.any(Date),
updatedAt: expect.any(Date),
parentFolder: null,
project: {
homeProject: {
id: expect.any(String),
name: expect.any(String),
type: expect.any(String),
@ -70,6 +69,23 @@ describe('FolderRepository', () => {
});
});
});
it('should filter folders by IDs', async () => {
const anotherUser = await createMember();
const anotherProject = await getPersonalProject(anotherUser);
const folder1 = await createFolder(project, { name: 'folder1' });
await createFolder(anotherProject, { name: 'folder2' });
const [folders, count] = await folderRepository.getManyAndCount({
filter: { folderIds: [folder1.id] },
});
expect(count).toBe(1);
expect(folders).toHaveLength(1);
expect(folders[0].name).toBe('folder1');
});
it('should filter folders by project ID', async () => {
const anotherUser = await createMember();
const anotherProject = await getPersonalProject(anotherUser);
@ -79,14 +95,14 @@ describe('FolderRepository', () => {
await Promise.all([folder1, folder2]);
const [folders, count] = await folderRepository.getMany({
const [folders, count] = await folderRepository.getManyAndCount({
filter: { projectId: project.id },
});
expect(count).toBe(1);
expect(folders).toHaveLength(1);
expect(folders[0].name).toBe('folder1');
expect(folders[0].project.id).toBe(project.id);
expect(folders[0].homeProject.id).toBe(project.id);
});
it('should filter folders by name case-insensitively', async () => {
@ -96,7 +112,7 @@ describe('FolderRepository', () => {
await Promise.all([folder1, folder2, folder3]);
const [folders, count] = await folderRepository.getMany({
const [folders, count] = await folderRepository.getManyAndCount({
filter: { name: 'test' },
});
@ -106,6 +122,8 @@ describe('FolderRepository', () => {
});
it('should filter folders by parent folder ID', async () => {
let folders: Folder[];
let count: number;
const parentFolder = await createFolder(project, { name: 'Parent' });
await createFolder(project, {
name: 'Child 1',
@ -117,7 +135,7 @@ describe('FolderRepository', () => {
});
await createFolder(project, { name: 'Unrelated' });
const [folders, count] = await folderRepository.getMany({
[folders, count] = await folderRepository.getManyAndCount({
filter: { parentFolderId: parentFolder.id },
});
@ -127,6 +145,17 @@ describe('FolderRepository', () => {
folders.forEach((folder) => {
expect(folder.parentFolder?.id).toBe(parentFolder.id);
});
[folders, count] = await folderRepository.getManyAndCount({
filter: { parentFolderId: '0' },
});
expect(count).toBe(2);
expect(folders).toHaveLength(2);
expect(folders.map((f) => f.name).sort()).toEqual(['Parent', 'Unrelated']);
folders.forEach((folder) => {
expect(folder.parentFolder).toBe(null);
});
});
it('should filter folders by a single tag', async () => {
@ -143,7 +172,7 @@ describe('FolderRepository', () => {
tags: [tag2],
});
const [folders, count] = await folderRepository.getMany({
const [folders, count] = await folderRepository.getManyAndCount({
filter: { tags: ['important'] },
});
@ -171,7 +200,7 @@ describe('FolderRepository', () => {
tags: [tag3],
});
const [folders] = await folderRepository.getMany({
const [folders] = await folderRepository.getManyAndCount({
filter: { tags: ['important', 'active'] },
});
@ -199,7 +228,7 @@ describe('FolderRepository', () => {
tags: [tag2],
});
const [folders, count] = await folderRepository.getMany({
const [folders, count] = await folderRepository.getManyAndCount({
filter: {
name: 'test',
parentFolderId: parentFolder.id,
@ -217,7 +246,6 @@ describe('FolderRepository', () => {
describe('select', () => {
let testFolder: Folder;
let workflowWithTestFolder: WorkflowEntity;
beforeEach(async () => {
const parentFolder = await createFolder(project, { name: 'Parent Folder' });
@ -227,11 +255,11 @@ describe('FolderRepository', () => {
parentFolder,
tags: [tag],
});
workflowWithTestFolder = await createWorkflow({ parentFolder: testFolder });
await createWorkflow({ parentFolder: testFolder });
});
it('should select only id and name when specified', async () => {
const [folders] = await folderRepository.getMany({
const [folders] = await folderRepository.getManyAndCount({
select: {
id: true,
name: true,
@ -251,7 +279,7 @@ describe('FolderRepository', () => {
});
it('should return id, name and tags when specified', async () => {
const [folders] = await folderRepository.getMany({
const [folders] = await folderRepository.getManyAndCount({
select: {
id: true,
name: true,
@ -276,7 +304,7 @@ describe('FolderRepository', () => {
});
it('should return id, name and project when specified', async () => {
const [folders] = await folderRepository.getMany({
const [folders] = await folderRepository.getManyAndCount({
select: {
id: true,
name: true,
@ -286,8 +314,8 @@ describe('FolderRepository', () => {
expect(folders).toHaveLength(2);
folders.forEach((folder) => {
expect(Object.keys(folder).sort()).toEqual(['id', 'name', 'project']);
expect(folder.project).toEqual({
expect(Object.keys(folder).sort()).toEqual(['homeProject', 'id', 'name']);
expect(folder.homeProject).toEqual({
id: expect.any(String),
name: expect.any(String),
type: expect.any(String),
@ -297,7 +325,7 @@ describe('FolderRepository', () => {
});
it('should return id, name and parentFolder when specified', async () => {
const [folders] = await folderRepository.getMany({
const [folders] = await folderRepository.getManyAndCount({
select: {
id: true,
name: true,
@ -320,29 +348,26 @@ describe('FolderRepository', () => {
});
});
it('should return id, name and workflows when specified', async () => {
const [folders] = await folderRepository.getMany({
it('should return id, name and workflowsCount when specified', async () => {
const [folders] = await folderRepository.getManyAndCount({
select: {
id: true,
name: true,
workflows: true,
workflowsCount: true,
},
});
expect(folders).toHaveLength(2);
folders.forEach((folder) => {
expect(Object.keys(folder).sort()).toEqual(['id', 'name', 'workflows']);
expect(Object.keys(folder).sort()).toEqual(['id', 'name', 'workflowsCount']);
expect(folder.id).toBeDefined();
expect(folder.name).toBeDefined();
expect(Array.isArray(folder.workflows)).toBeTruthy();
expect(folder.workflowsCount).toBeDefined();
});
expect(folders[0].workflows).toHaveLength(1);
expect(folders[0].workflows[0].id).toBe(workflowWithTestFolder.id);
});
it('should return timestamps when specified', async () => {
const [folders] = await folderRepository.getMany({
const [folders] = await folderRepository.getManyAndCount({
select: {
id: true,
createdAt: true,
@ -359,7 +384,7 @@ describe('FolderRepository', () => {
});
it('should return all properties when no select is specified', async () => {
const [folders] = await folderRepository.getMany();
const [folders] = await folderRepository.getManyAndCount();
expect(folders).toHaveLength(2);
folders.forEach((folder) => {
@ -368,13 +393,13 @@ describe('FolderRepository', () => {
name: expect.any(String),
createdAt: expect.any(Date),
updatedAt: expect.any(Date),
project: {
homeProject: {
id: expect.any(String),
name: expect.any(String),
type: expect.any(String),
icon: null,
},
workflows: expect.any(Array),
workflowsCount: expect.any(Number),
tags: expect.any(Array),
});
});
@ -408,7 +433,7 @@ describe('FolderRepository', () => {
});
it('should limit results when take is specified', async () => {
const [folders, count] = await folderRepository.getMany({
const [folders, count] = await folderRepository.getManyAndCount({
take: 3,
});
@ -417,7 +442,7 @@ describe('FolderRepository', () => {
});
it('should skip results when skip is specified', async () => {
const [folders, count] = await folderRepository.getMany({
const [folders, count] = await folderRepository.getManyAndCount({
skip: 2,
take: 5,
});
@ -428,7 +453,7 @@ describe('FolderRepository', () => {
});
it('should handle skip and take together', async () => {
const [folders, count] = await folderRepository.getMany({
const [folders, count] = await folderRepository.getManyAndCount({
skip: 1,
take: 2,
});
@ -439,7 +464,7 @@ describe('FolderRepository', () => {
});
it('should handle take larger than remaining items', async () => {
const [folders, count] = await folderRepository.getMany({
const [folders, count] = await folderRepository.getManyAndCount({
skip: 3,
take: 10,
});
@ -450,7 +475,7 @@ describe('FolderRepository', () => {
});
it('should handle zero take by returning all results', async () => {
const [folders, count] = await folderRepository.getMany({
const [folders, count] = await folderRepository.getManyAndCount({
take: 0,
});
@ -495,7 +520,7 @@ describe('FolderRepository', () => {
});
it('should sort by default (updatedAt:desc)', async () => {
const [folders] = await folderRepository.getMany();
const [folders] = await folderRepository.getManyAndCount();
expect(folders.map((f) => f.name)).toEqual([
'C Folder',
@ -506,7 +531,7 @@ describe('FolderRepository', () => {
});
it('should sort by name:asc', async () => {
const [folders] = await folderRepository.getMany({
const [folders] = await folderRepository.getManyAndCount({
sortBy: 'name:asc',
});
@ -519,7 +544,7 @@ describe('FolderRepository', () => {
});
it('should sort by name:desc', async () => {
const [folders] = await folderRepository.getMany({
const [folders] = await folderRepository.getManyAndCount({
sortBy: 'name:desc',
});
@ -532,7 +557,7 @@ describe('FolderRepository', () => {
});
it('should sort by createdAt:asc', async () => {
const [folders] = await folderRepository.getMany({
const [folders] = await folderRepository.getManyAndCount({
sortBy: 'createdAt:asc',
});
@ -545,7 +570,7 @@ describe('FolderRepository', () => {
});
it('should sort by createdAt:desc', async () => {
const [folders] = await folderRepository.getMany({
const [folders] = await folderRepository.getManyAndCount({
sortBy: 'createdAt:desc',
});
@ -558,7 +583,7 @@ describe('FolderRepository', () => {
});
it('should sort by updatedAt:asc', async () => {
const [folders] = await folderRepository.getMany({
const [folders] = await folderRepository.getManyAndCount({
sortBy: 'updatedAt:asc',
});
@ -571,7 +596,7 @@ describe('FolderRepository', () => {
});
it('should sort by updatedAt:desc', async () => {
const [folders] = await folderRepository.getMany({
const [folders] = await folderRepository.getManyAndCount({
sortBy: 'updatedAt:desc',
});
@ -584,7 +609,7 @@ describe('FolderRepository', () => {
});
it('should default to asc if order not specified', async () => {
const [folders] = await folderRepository.getMany({
const [folders] = await folderRepository.getManyAndCount({
sortBy: 'name',
});

View file

@ -4,17 +4,30 @@ import { DataSource, Repository } from '@n8n/typeorm';
import type { ListQuery } from '@/requests';
import type { FolderWithWorkflowsCount } from '../entities/folder';
import { Folder } from '../entities/folder';
import { FolderTagMapping } from '../entities/folder-tag-mapping';
import { TagEntity } from '../entities/tag-entity';
@Service()
export class FolderRepository extends Repository<Folder> {
export class FolderRepository extends Repository<FolderWithWorkflowsCount> {
constructor(dataSource: DataSource) {
super(Folder, dataSource.manager);
}
async getMany(options: ListQuery.Options = {}): Promise<[Folder[], number]> {
async getManyAndCount(
options: ListQuery.Options = {},
): Promise<[FolderWithWorkflowsCount[], number]> {
const query = this.getManyQuery(options);
return await query.getManyAndCount();
}
async getMany(options: ListQuery.Options = {}): Promise<FolderWithWorkflowsCount[]> {
const query = this.getManyQuery(options);
return await query.getMany();
}
getManyQuery(options: ListQuery.Options = {}): SelectQueryBuilder<FolderWithWorkflowsCount> {
const query = this.createQueryBuilder('folder');
this.applySelections(query, options.select);
@ -22,11 +35,11 @@ export class FolderRepository extends Repository<Folder> {
this.applySorting(query, options.sortBy);
this.applyPagination(query, options);
return await query.getManyAndCount();
return query;
}
private applySelections(
query: SelectQueryBuilder<Folder>,
query: SelectQueryBuilder<FolderWithWorkflowsCount>,
select?: Record<string, boolean>,
): void {
if (select) {
@ -36,23 +49,22 @@ export class FolderRepository extends Repository<Folder> {
}
}
private applyDefaultSelect(query: SelectQueryBuilder<Folder>): void {
private applyDefaultSelect(query: SelectQueryBuilder<FolderWithWorkflowsCount>): void {
query
.leftJoinAndSelect('folder.project', 'project')
.leftJoinAndSelect('folder.homeProject', 'homeProject')
.leftJoinAndSelect('folder.parentFolder', 'parentFolder')
.leftJoinAndSelect('folder.tags', 'tags')
.leftJoinAndSelect('folder.workflows', 'workflows')
.loadRelationCountAndMap('folder.workflowsCount', 'folder.workflows')
.select([
'folder',
...this.getProjectFields('project'),
...this.getProjectFields('homeProject'),
...this.getTagFields(),
...this.getParentFolderFields('parentFolder'),
'workflows.id',
]);
}
private applyCustomSelect(
query: SelectQueryBuilder<Folder>,
query: SelectQueryBuilder<FolderWithWorkflowsCount>,
select?: Record<string, boolean>,
): void {
const selections = ['folder.id'];
@ -70,13 +82,13 @@ export class FolderRepository extends Repository<Folder> {
}
private addRelationFields(
query: SelectQueryBuilder<Folder>,
query: SelectQueryBuilder<FolderWithWorkflowsCount>,
selections: string[],
select?: Record<string, boolean>,
): void {
if (select?.project) {
query.leftJoin('folder.project', 'project');
selections.push(...this.getProjectFields('project'));
query.leftJoin('folder.homeProject', 'homeProject');
selections.push(...this.getProjectFields('homeProject'));
}
if (select?.tags) {
@ -89,9 +101,8 @@ export class FolderRepository extends Repository<Folder> {
selections.push(...this.getParentFolderFields('parentFolder'));
}
if (select?.workflows) {
query.leftJoinAndSelect('folder.workflows', 'workflows');
selections.push('workflows.id');
if (select?.workflowsCount) {
query.loadRelationCountAndMap('folder.workflowsCount', 'folder.workflows');
}
}
@ -108,7 +119,7 @@ export class FolderRepository extends Repository<Folder> {
}
private applyFilters(
query: SelectQueryBuilder<Folder>,
query: SelectQueryBuilder<FolderWithWorkflowsCount>,
filter?: ListQuery.Options['filter'],
): void {
if (!filter) return;
@ -118,9 +129,19 @@ export class FolderRepository extends Repository<Folder> {
}
private applyBasicFilters(
query: SelectQueryBuilder<Folder>,
query: SelectQueryBuilder<FolderWithWorkflowsCount>,
filter: ListQuery.Options['filter'],
): void {
if (filter?.folderIds && Array.isArray(filter.folderIds)) {
query.andWhere('folder.id IN (:...folderIds)', {
/*
* If folderIds is empty, add a dummy value to prevent an error
* when using the IN operator with an empty array.
*/
folderIds: !filter?.folderIds.length ? [''] : filter?.folderIds,
});
}
if (filter?.projectId) {
query.andWhere('folder.projectId = :projectId', { projectId: filter.projectId });
}
@ -131,14 +152,19 @@ export class FolderRepository extends Repository<Folder> {
});
}
if (filter?.parentFolderId) {
if (filter?.parentFolderId === '0') {
query.andWhere('folder.parentFolderId IS NULL');
} else if (filter?.parentFolderId) {
query.andWhere('folder.parentFolderId = :parentFolderId', {
parentFolderId: filter.parentFolderId,
});
}
}
private applyTagsFilter(query: SelectQueryBuilder<Folder>, tags?: string[]): void {
private applyTagsFilter(
query: SelectQueryBuilder<FolderWithWorkflowsCount>,
tags?: string[],
): void {
if (!Array.isArray(tags) || tags.length === 0) return;
const subQuery = this.createTagsSubQuery(query, tags);
@ -150,7 +176,7 @@ export class FolderRepository extends Repository<Folder> {
}
private createTagsSubQuery(
query: SelectQueryBuilder<Folder>,
query: SelectQueryBuilder<FolderWithWorkflowsCount>,
tags: string[],
): SelectQueryBuilder<FolderTagMapping> {
return query
@ -165,7 +191,7 @@ export class FolderRepository extends Repository<Folder> {
});
}
private applySorting(query: SelectQueryBuilder<Folder>, sortBy?: string): void {
private applySorting(query: SelectQueryBuilder<FolderWithWorkflowsCount>, sortBy?: string): void {
if (!sortBy) {
query.orderBy('folder.updatedAt', 'DESC');
return;
@ -181,7 +207,7 @@ export class FolderRepository extends Repository<Folder> {
}
private applySortingByField(
query: SelectQueryBuilder<Folder>,
query: SelectQueryBuilder<FolderWithWorkflowsCount>,
field: string,
direction: 'DESC' | 'ASC',
): void {
@ -192,7 +218,10 @@ export class FolderRepository extends Repository<Folder> {
}
}
private applyPagination(query: SelectQueryBuilder<Folder>, options: ListQuery.Options): void {
private applyPagination(
query: SelectQueryBuilder<FolderWithWorkflowsCount>,
options: ListQuery.Options,
): void {
if (options?.take) {
query.skip(options.skip ?? 0).take(options.take);
}

View file

@ -13,16 +13,38 @@ import type {
import type { ListQuery } from '@/requests';
import { isStringArray } from '@/utils';
import { FolderRepository } from './folder.repository';
import type { Folder, FolderWithWorkflowsCount } from '../entities/folder';
import { TagEntity } from '../entities/tag-entity';
import { WebhookEntity } from '../entities/webhook-entity';
import { WorkflowEntity } from '../entities/workflow-entity';
import { WorkflowTagMapping } from '../entities/workflow-tag-mapping';
type ResourceType = 'folder' | 'workflow';
type WorkflowFolderUnionRow = {
id: string;
name: string;
name_lower?: string;
resource: ResourceType;
createdAt: Date;
updatedAt: Date;
};
export type WorkflowFolderUnionFull = (
| ListQuery.Workflow.Plain
| ListQuery.Workflow.WithSharing
| FolderWithWorkflowsCount
) & {
resource: ResourceType;
};
@Service()
export class WorkflowRepository extends Repository<WorkflowEntity> {
constructor(
dataSource: DataSource,
private readonly globalConfig: GlobalConfig,
private readonly folderRepository: FolderRepository,
) {
super(WorkflowEntity, dataSource.manager);
}
@ -99,20 +121,218 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
.execute();
}
async getMany(sharedWorkflowIds: string[], options: ListQuery.Options = {}) {
private buildBaseUnionQuery(workflowIds: string[], options: ListQuery.Options = {}) {
const subQueryParameters: ListQuery.Options = {
select: {
createdAt: true,
updatedAt: true,
id: true,
name: true,
},
filter: options.filter,
};
const columnNames = [...Object.keys(subQueryParameters.select ?? {}), 'resource'];
const [sortByColumn, sortByDirection] = this.parseSortingParams(
options.sortBy ?? 'updatedAt:asc',
);
const foldersQuery = this.folderRepository
.getManyQuery(subQueryParameters)
.addSelect("'folder'", 'resource');
const workflowsQuery = this.getManyQuery(workflowIds, subQueryParameters).addSelect(
"'workflow'",
'resource',
);
const qb = this.manager.createQueryBuilder();
return {
baseQuery: qb
.createQueryBuilder()
.addCommonTableExpression(foldersQuery, 'FOLDERS_QUERY', { columnNames })
.addCommonTableExpression(workflowsQuery, 'WORKFLOWS_QUERY', { columnNames })
.addCommonTableExpression(
`SELECT * FROM ${qb.escape('FOLDERS_QUERY')} UNION ALL SELECT * FROM ${qb.escape('WORKFLOWS_QUERY')}`,
'RESULT_QUERY',
),
sortByColumn,
sortByDirection,
};
}
async getWorkflowsAndFoldersUnion(workflowIds: string[], options: ListQuery.Options = {}) {
const { baseQuery, sortByColumn, sortByDirection } = this.buildBaseUnionQuery(
workflowIds,
options,
);
const query = this.buildUnionQuery(baseQuery, {
sortByColumn,
sortByDirection,
pagination: {
take: options.take,
skip: options.skip ?? 0,
},
});
const workflowsAndFolders = await query.getRawMany<WorkflowFolderUnionRow>();
return this.removeNameLowerFromResults(workflowsAndFolders);
}
private buildUnionQuery(
baseQuery: SelectQueryBuilder<any>,
options: {
sortByColumn: string;
sortByDirection: 'ASC' | 'DESC';
pagination: {
take?: number;
skip: number;
};
},
) {
const query = baseQuery
.select(`${baseQuery.escape('RESULT')}.*`)
.from('RESULT_QUERY', 'RESULT');
this.applySortingToUnionQuery(query, baseQuery, options);
this.applyPaginationToUnionQuery(query, options.pagination);
return query;
}
private applySortingToUnionQuery(
query: SelectQueryBuilder<any>,
baseQuery: SelectQueryBuilder<any>,
options: { sortByColumn: string; sortByDirection: 'ASC' | 'DESC' },
) {
const { sortByColumn, sortByDirection } = options;
const resultTableEscaped = baseQuery.escape('RESULT');
const nameColumnEscaped = baseQuery.escape('name');
const resourceColumnEscaped = baseQuery.escape('resource');
const sortByColumnEscaped = baseQuery.escape(sortByColumn);
// Guarantee folders show up first
query.orderBy(`${resultTableEscaped}.${resourceColumnEscaped}`, 'ASC');
if (sortByColumn === 'name') {
query
.addSelect(`LOWER(${resultTableEscaped}.${nameColumnEscaped})`, 'name_lower')
.addOrderBy('name_lower', sortByDirection);
} else {
query.addOrderBy(`${resultTableEscaped}.${sortByColumnEscaped}`, sortByDirection);
}
}
private applyPaginationToUnionQuery(
query: SelectQueryBuilder<any>,
pagination: { take?: number; skip: number },
) {
if (pagination.take) {
query.take(pagination.take);
}
query.skip(pagination.skip);
}
private removeNameLowerFromResults(results: WorkflowFolderUnionRow[]) {
return results.map(({ name_lower, ...rest }) => rest);
}
async getWorkflowsAndFoldersCount(workflowIds: string[], options: ListQuery.Options = {}) {
const { skip, take, ...baseQueryParameters } = options;
const { baseQuery } = this.buildBaseUnionQuery(workflowIds, baseQueryParameters);
const response = await baseQuery
.select(`COUNT(DISTINCT ${baseQuery.escape('RESULT')}.${baseQuery.escape('id')})`, 'count')
.from('RESULT_QUERY', 'RESULT')
.select('COUNT(*)', 'count')
.getRawOne<{ count: number | string }>();
return Number(response?.count) || 0;
}
async getWorkflowsAndFoldersWithCount(workflowIds: string[], options: ListQuery.Options = {}) {
const [workflowsAndFolders, count] = await Promise.all([
this.getWorkflowsAndFoldersUnion(workflowIds, options),
this.getWorkflowsAndFoldersCount(workflowIds, options),
]);
const { workflows, folders } = await this.fetchExtraData(workflowsAndFolders);
const enrichedWorkflowsAndFolders = this.enrichDataWithExtras(workflowsAndFolders, {
workflows,
folders,
});
return [enrichedWorkflowsAndFolders, count] as const;
}
private getFolderIds(workflowsAndFolders: WorkflowFolderUnionRow[]) {
return workflowsAndFolders.filter((item) => item.resource === 'folder').map((item) => item.id);
}
private getWorkflowsIds(workflowsAndFolders: WorkflowFolderUnionRow[]) {
return workflowsAndFolders
.filter((item) => item.resource === 'workflow')
.map((item) => item.id);
}
private async fetchExtraData(workflowsAndFolders: WorkflowFolderUnionRow[]) {
const workflowIds = this.getWorkflowsIds(workflowsAndFolders);
const folderIds = this.getFolderIds(workflowsAndFolders);
const [workflows, folders] = await Promise.all([
this.getMany(workflowIds),
this.folderRepository.getMany({ filter: { folderIds } }),
]);
return { workflows, folders };
}
private enrichDataWithExtras(
baseData: WorkflowFolderUnionRow[],
extraData: {
workflows: ListQuery.Workflow.WithSharing[] | ListQuery.Workflow.Plain[];
folders: Folder[];
},
): WorkflowFolderUnionFull[] {
const workflowsMap = new Map(extraData.workflows.map((workflow) => [workflow.id, workflow]));
const foldersMap = new Map(extraData.folders.map((folder) => [folder.id, folder]));
return baseData.map((item) => {
const lookupMap = item.resource === 'folder' ? foldersMap : workflowsMap;
const extraItem = lookupMap.get(item.id);
return extraItem ? { ...item, ...extraItem } : item;
});
}
async getMany(workflowIds: string[], options: ListQuery.Options = {}) {
if (workflowIds.length === 0) {
return [];
}
const query = this.getManyQuery(workflowIds, options);
const workflows = (await query.getMany()) as
| ListQuery.Workflow.Plain[]
| ListQuery.Workflow.WithSharing[];
return workflows;
}
async getManyAndCount(sharedWorkflowIds: string[], options: ListQuery.Options = {}) {
if (sharedWorkflowIds.length === 0) {
return { workflows: [], count: 0 };
}
const qb = this.createBaseQuery(sharedWorkflowIds);
const query = this.getManyQuery(sharedWorkflowIds, options);
this.applyFilters(qb, options.filter);
this.applySelect(qb, options.select);
this.applyRelations(qb, options.select);
this.applySorting(qb, options.sortBy);
this.applyPagination(qb, options);
const [workflows, count] = (await qb.getManyAndCount()) as [
const [workflows, count] = (await query.getManyAndCount()) as [
ListQuery.Workflow.Plain[] | ListQuery.Workflow.WithSharing[],
number,
];
@ -120,9 +340,25 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
return { workflows, count };
}
private createBaseQuery(sharedWorkflowIds: string[]): SelectQueryBuilder<WorkflowEntity> {
return this.createQueryBuilder('workflow').where('workflow.id IN (:...sharedWorkflowIds)', {
sharedWorkflowIds,
getManyQuery(workflowIds: string[], options: ListQuery.Options = {}) {
const qb = this.createBaseQuery(workflowIds);
this.applyFilters(qb, options.filter);
this.applySelect(qb, options.select);
this.applyRelations(qb, options.select);
this.applySorting(qb, options.sortBy);
this.applyPagination(qb, options);
return qb;
}
private createBaseQuery(workflowIds: string[]): SelectQueryBuilder<WorkflowEntity> {
return this.createQueryBuilder('workflow').where('workflow.id IN (:...workflowIds)', {
/*
* If workflowIds is empty, add a dummy value to prevent an error
* when using the IN operator with an empty array.
*/
workflowIds: !workflowIds.length ? [''] : workflowIds,
});
}
@ -130,12 +366,11 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
qb: SelectQueryBuilder<WorkflowEntity>,
filter?: ListQuery.Options['filter'],
): void {
if (!filter) return;
this.applyNameFilter(qb, filter);
this.applyActiveFilter(qb, filter);
this.applyTagsFilter(qb, filter);
this.applyProjectFilter(qb, filter);
this.applyParentFolderFilter(qb, filter);
}
private applyNameFilter(
@ -149,6 +384,19 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
}
}
private applyParentFolderFilter(
qb: SelectQueryBuilder<WorkflowEntity>,
filter: ListQuery.Options['filter'],
): void {
if (filter?.parentFolderId === '0') {
qb.andWhere('workflow.parentFolderId IS NULL');
} else if (filter?.parentFolderId) {
qb.andWhere('workflow.parentFolderId = :parentFolderId', {
parentFolderId: filter.parentFolderId,
});
}
}
private applyActiveFilter(
qb: SelectQueryBuilder<WorkflowEntity>,
filter: ListQuery.Options['filter'],
@ -219,12 +467,11 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
qb: SelectQueryBuilder<WorkflowEntity>,
select?: Record<string, boolean>,
): void {
// Always start with workflow.id
qb.select(['workflow.id']);
if (!select) {
// Default select fields when no select option provided
qb.addSelect([
// Instead of selecting id first and then adding more fields,
// select all fields at once
qb.select([
'workflow.id',
'workflow.name',
'workflow.active',
'workflow.createdAt',
@ -234,17 +481,23 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
return;
}
// For custom select, still start with ID but don't add it again
const fieldsToSelect = ['workflow.id'];
// Handle special fields separately
const regularFields = Object.entries(select).filter(
([field]) => !['ownedBy', 'tags'].includes(field),
([field]) => !['ownedBy', 'tags', 'parentFolder'].includes(field),
);
// Add regular fields
regularFields.forEach(([field, include]) => {
if (include) {
qb.addSelect(`workflow.${field}`);
if (include && field !== 'id') {
// Skip id since we already added it
fieldsToSelect.push(`workflow.${field}`);
}
});
qb.select(fieldsToSelect);
}
private applyRelations(
@ -255,6 +508,11 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
const isDefaultSelect = select === undefined;
const areTagsRequested = isDefaultSelect || select?.tags;
const isOwnedByIncluded = isDefaultSelect || select?.ownedBy;
const isParentFolderIncluded = isDefaultSelect || select?.parentFolder;
if (isParentFolderIncluded) {
qb.leftJoinAndSelect('workflow.parentFolder', 'parentFolder');
}
if (areTagsEnabled && areTagsRequested) {
this.applyTagsRelation(qb);
@ -273,7 +531,7 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
private applySorting(qb: SelectQueryBuilder<WorkflowEntity>, sortBy?: string): void {
if (!sortBy) {
this.applyDefaultSorting(qb);
qb.orderBy('workflow.updatedAt', 'ASC');
return;
}
@ -286,10 +544,6 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
return [column, order.toUpperCase() as 'ASC' | 'DESC'];
}
private applyDefaultSorting(qb: SelectQueryBuilder<WorkflowEntity>): void {
qb.orderBy('workflow.updatedAt', 'ASC');
}
private applySortingByColumn(
qb: SelectQueryBuilder<WorkflowEntity>,
column: string,

View file

@ -25,6 +25,11 @@ export class WorkflowFilter extends BaseFilter {
@Expose()
projectId?: string;
@IsString()
@IsOptional()
@Expose()
parentFolderId?: string;
static async fromString(rawFilter: string) {
return await this.toFilter(rawFilter, WorkflowFilter);
}

View file

@ -11,6 +11,7 @@ export class WorkflowSelect extends BaseSelect {
'updatedAt',
'versionId',
'ownedBy', // non-entity field
'parentFolder',
]);
}

View file

@ -149,7 +149,12 @@ export declare namespace CredentialRequest {
type Get = AuthenticatedRequest<{ credentialId: string }, {}, {}, Record<string, string>>;
type GetMany = AuthenticatedRequest<{}, {}, {}, ListQuery.Params & { includeScopes?: string }> & {
type GetMany = AuthenticatedRequest<
{},
{},
{},
ListQuery.Params & { includeScopes?: string; includeFolders?: string }
> & {
listQueryOptions: ListQuery.Options;
};

View file

@ -40,7 +40,12 @@ export declare namespace WorkflowRequest {
type Get = AuthenticatedRequest<{ workflowId: string }>;
type GetMany = AuthenticatedRequest<{}, {}, {}, ListQuery.Params & { includeScopes?: string }> & {
type GetMany = AuthenticatedRequest<
{},
{},
{},
ListQuery.Params & { includeScopes?: string; includeFolders?: string }
> & {
listQueryOptions: ListQuery.Options;
};

View file

@ -19,6 +19,7 @@ import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
import { WorkflowTagMappingRepository } from '@/databases/repositories/workflow-tag-mapping.repository';
import type { WorkflowFolderUnionFull } from '@/databases/repositories/workflow.repository';
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
@ -58,13 +59,33 @@ export class WorkflowService {
private readonly globalConfig: GlobalConfig,
) {}
async getMany(user: User, options?: ListQuery.Options, includeScopes?: boolean) {
async getMany(
user: User,
options?: ListQuery.Options,
includeScopes?: boolean,
includeFolders?: boolean,
) {
let count;
let workflows;
let workflowsAndFolders: WorkflowFolderUnionFull[] = [];
const sharedWorkflowIds = await this.workflowSharingService.getSharedWorkflowIds(user, {
scopes: ['workflow:read'],
});
// eslint-disable-next-line prefer-const
let { workflows, count } = await this.workflowRepository.getMany(sharedWorkflowIds, options);
if (includeFolders) {
[workflowsAndFolders, count] = await this.workflowRepository.getWorkflowsAndFoldersWithCount(
sharedWorkflowIds,
options,
);
workflows = workflowsAndFolders.filter((wf) => wf.resource === 'workflow');
} else {
({ workflows, count } = await this.workflowRepository.getManyAndCount(
sharedWorkflowIds,
options,
));
}
/*
Since we're filtering using project ID as part of the relation,
@ -83,6 +104,10 @@ export class WorkflowService {
this.cleanupSharedField(workflows);
if (includeFolders) {
workflows = this.mergeProcessedWorkflows(workflowsAndFolders, workflows);
}
return {
workflows,
count,
@ -137,6 +162,17 @@ export class WorkflowService {
});
}
private mergeProcessedWorkflows(
workflowsAndFolders: WorkflowFolderUnionFull[],
processedWorkflows: ListQuery.Workflow.Plain[] | ListQuery.Workflow.WithSharing[],
) {
const workflowMap = new Map(processedWorkflows.map((workflow) => [workflow.id, workflow]));
return workflowsAndFolders.map((item) =>
item.resource === 'workflow' ? (workflowMap.get(item.id) ?? item) : item,
);
}
// eslint-disable-next-line complexity
async update(
user: User,

View file

@ -212,6 +212,7 @@ export class WorkflowsController {
req.user,
req.listQueryOptions,
!!req.query.includeScopes,
!!req.query.includeFolders,
);
res.json({ count, data });

View file

@ -20,7 +20,7 @@ export const createFolder = async (
const folder = await folderRepository.save(
folderRepository.create({
name: options.name ?? randomName(),
project,
homeProject: project,
parentFolder: options.parentFolder ?? null,
tags: options.tags ?? [],
updatedAt: options.updatedAt ?? new Date(),

View file

@ -9,11 +9,13 @@ import type { User } from '@/databases/entities/user';
import { ProjectRepository } from '@/databases/repositories/project.repository';
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
import { WorkflowHistoryRepository } from '@/databases/repositories/workflow-history.repository';
import type { WorkflowFolderUnionFull } from '@/databases/repositories/workflow.repository';
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
import { License } from '@/license';
import type { ListQuery } from '@/requests';
import { ProjectService } from '@/services/project.service.ee';
import { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee';
import { createFolder } from '@test-integration/db/folders';
import { mockInstance } from '../../shared/mocking';
import { saveCredential } from '../shared/db/credentials';
@ -62,6 +64,7 @@ beforeEach(async () => {
'WorkflowHistory',
'Project',
'ProjectRelation',
'Folder',
]);
projectRepository = Container.get(ProjectRepository);
projectService = Container.get(ProjectService);
@ -723,6 +726,33 @@ describe('GET /workflows', () => {
expect(response2.body.data).toHaveLength(0);
});
test('should filter workflows by parentFolderId', async () => {
const pp = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(owner.id);
const folder1 = await createFolder(pp, { name: 'Folder 1' });
const workflow1 = await createWorkflow({ name: 'First', parentFolder: folder1 }, owner);
const workflow2 = await createWorkflow({ name: 'Second' }, owner);
const response1 = await authOwnerAgent
.get('/workflows')
.query(`filter={ "parentFolderId": "${folder1.id}" }`)
.expect(200);
expect(response1.body.data).toHaveLength(1);
expect(response1.body.data[0].id).toBe(workflow1.id);
// if not provided, looks for workflows without a parentFolder
const response2 = await authOwnerAgent
.get('/workflows')
.query('filter={ "parentFolderId": "0" }');
expect(200);
expect(response2.body.data).toHaveLength(1);
expect(response2.body.data[0].id).toBe(workflow2.id);
});
test('should return homeProject when filtering workflows by projectId', async () => {
const workflow = await createWorkflow({ name: 'First' }, owner);
await shareWorkflowWithUsers(workflow, [member]);
@ -893,6 +923,40 @@ describe('GET /workflows', () => {
]),
});
});
test('should select workflow field: parentFolder', async () => {
const ownerPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(
owner.id,
);
const folder = await createFolder(ownerPersonalProject, { name: 'Folder 1' });
await createWorkflow({ parentFolder: folder }, owner);
await createWorkflow({}, owner);
const response = await authOwnerAgent
.get('/workflows')
.query('select=["parentFolder"]')
.expect(200);
expect(response.body).toEqual({
count: 2,
data: arrayContaining([
{
id: any(String),
parentFolder: {
id: folder.id,
name: folder.name,
createdAt: expect.any(String),
updatedAt: expect.any(String),
},
},
{
id: any(String),
parentFolder: null,
},
]),
});
});
});
describe('sortBy', () => {
@ -1056,6 +1120,647 @@ describe('GET /workflows', () => {
});
});
describe('GET /workflows?includeFolders=true', () => {
test('should return zero workflows and folders if none exist', async () => {
const response = await authOwnerAgent.get('/workflows').query({ includeFolders: true });
expect(response.body).toEqual({ count: 0, data: [] });
});
test('should return workflows and folders', async () => {
const credential = await saveCredential(randomCredentialPayload(), {
user: owner,
role: 'credential:owner',
});
const ownerPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id);
const nodes: INode[] = [
{
id: uuid(),
name: 'Action Network',
type: 'n8n-nodes-base.actionNetwork',
parameters: {},
typeVersion: 1,
position: [0, 0],
credentials: {
actionNetworkApi: {
id: credential.id,
name: credential.name,
},
},
},
];
const tag = await createTag({ name: 'A' });
await createWorkflow({ name: 'First', nodes, tags: [tag] }, owner);
await createWorkflow({ name: 'Second' }, owner);
await createFolder(ownerPersonalProject, { name: 'Folder' });
const response = await authOwnerAgent.get('/workflows').query({ includeFolders: true });
expect(200);
expect(response.body).toEqual({
count: 3,
data: arrayContaining([
objectContaining({
resource: 'workflow',
id: any(String),
name: 'First',
active: any(Boolean),
createdAt: any(String),
updatedAt: any(String),
tags: [{ id: any(String), name: 'A' }],
versionId: any(String),
homeProject: {
id: ownerPersonalProject.id,
name: owner.createPersonalProjectName(),
icon: null,
type: ownerPersonalProject.type,
},
sharedWithProjects: [],
}),
objectContaining({
id: any(String),
name: 'Second',
active: any(Boolean),
createdAt: any(String),
updatedAt: any(String),
tags: [],
versionId: any(String),
homeProject: {
id: ownerPersonalProject.id,
name: owner.createPersonalProjectName(),
icon: null,
type: ownerPersonalProject.type,
},
sharedWithProjects: [],
}),
objectContaining({
resource: 'folder',
id: any(String),
name: 'Folder',
createdAt: any(String),
updatedAt: any(String),
tags: [],
homeProject: {
id: ownerPersonalProject.id,
name: owner.createPersonalProjectName(),
icon: null,
type: ownerPersonalProject.type,
},
parentFolder: null,
workflowsCount: 0,
}),
]),
});
const found = response.body.data.find(
(w: ListQuery.Workflow.WithOwnership) => w.name === 'First',
);
expect(found.nodes).toBeUndefined();
expect(found.sharedWithProjects).toHaveLength(0);
expect(found.usedCredentials).toBeUndefined();
});
test('should return workflows with scopes and folders when ?includeScopes=true', async () => {
const [member1, member2] = await createManyUsers(2, {
role: 'global:member',
});
const teamProject = await createTeamProject(undefined, member1);
await linkUserToProject(member2, teamProject, 'project:editor');
const credential = await saveCredential(randomCredentialPayload(), {
user: owner,
role: 'credential:owner',
});
const nodes: INode[] = [
{
id: uuid(),
name: 'Action Network',
type: 'n8n-nodes-base.actionNetwork',
parameters: {},
typeVersion: 1,
position: [0, 0],
credentials: {
actionNetworkApi: {
id: credential.id,
name: credential.name,
},
},
},
];
const tag = await createTag({ name: 'A' });
const [savedWorkflow1, savedWorkflow2, savedFolder1] = await Promise.all([
createWorkflow({ name: 'First', nodes, tags: [tag] }, teamProject),
createWorkflow({ name: 'Second' }, member2),
createFolder(teamProject, { name: 'Folder' }),
]);
await shareWorkflowWithProjects(savedWorkflow2, [{ project: teamProject }]);
{
const response = await testServer
.authAgentFor(member1)
.get('/workflows?includeScopes=true&includeFolders=true');
expect(response.statusCode).toBe(200);
expect(response.body.data.length).toBe(3);
const workflows = response.body.data as Array<WorkflowFolderUnionFull & { scopes: Scope[] }>;
const wf1 = workflows.find((wf) => wf.id === savedWorkflow1.id)!;
const wf2 = workflows.find((wf) => wf.id === savedWorkflow2.id)!;
const f1 = workflows.find((wf) => wf.id === savedFolder1.id)!;
// Team workflow
expect(wf1.id).toBe(savedWorkflow1.id);
expect(wf1.scopes).toEqual(
[
'workflow:delete',
'workflow:execute',
'workflow:move',
'workflow:read',
'workflow:update',
].sort(),
);
// Shared workflow
expect(wf2.id).toBe(savedWorkflow2.id);
expect(wf2.scopes).toEqual(['workflow:read', 'workflow:update', 'workflow:execute'].sort());
expect(f1.id).toBe(savedFolder1.id);
}
{
const response = await testServer
.authAgentFor(member2)
.get('/workflows?includeScopes=true&includeFolders=true');
expect(response.statusCode).toBe(200);
expect(response.body.data.length).toBe(3);
const workflows = response.body.data as Array<WorkflowFolderUnionFull & { scopes: Scope[] }>;
const wf1 = workflows.find((w) => w.id === savedWorkflow1.id)!;
const wf2 = workflows.find((w) => w.id === savedWorkflow2.id)!;
const f1 = workflows.find((wf) => wf.id === savedFolder1.id)!;
// Team workflow
expect(wf1.id).toBe(savedWorkflow1.id);
expect(wf1.scopes).toEqual([
'workflow:delete',
'workflow:execute',
'workflow:read',
'workflow:update',
]);
// Shared workflow
expect(wf2.id).toBe(savedWorkflow2.id);
expect(wf2.scopes).toEqual(
[
'workflow:delete',
'workflow:execute',
'workflow:move',
'workflow:read',
'workflow:share',
'workflow:update',
].sort(),
);
expect(f1.id).toBe(savedFolder1.id);
}
{
const response = await testServer
.authAgentFor(owner)
.get('/workflows?includeScopes=true&includeFolders=true');
expect(response.statusCode).toBe(200);
expect(response.body.data.length).toBe(3);
const workflows = response.body.data as Array<WorkflowFolderUnionFull & { scopes: Scope[] }>;
const wf1 = workflows.find((w) => w.id === savedWorkflow1.id)!;
const wf2 = workflows.find((w) => w.id === savedWorkflow2.id)!;
const f1 = workflows.find((wf) => wf.id === savedFolder1.id)!;
// Team workflow
expect(wf1.id).toBe(savedWorkflow1.id);
expect(wf1.scopes).toEqual(
[
'workflow:create',
'workflow:delete',
'workflow:execute',
'workflow:list',
'workflow:move',
'workflow:read',
'workflow:share',
'workflow:update',
].sort(),
);
// Shared workflow
expect(wf2.id).toBe(savedWorkflow2.id);
expect(wf2.scopes).toEqual(
[
'workflow:create',
'workflow:delete',
'workflow:execute',
'workflow:list',
'workflow:move',
'workflow:read',
'workflow:share',
'workflow:update',
].sort(),
);
expect(f1.id).toBe(savedFolder1.id);
}
});
describe('filter', () => {
test('should filter workflows and folders by field: name', async () => {
const workflow1 = await createWorkflow({ name: 'First' }, owner);
await createWorkflow({ name: 'Second' }, owner);
const ownerProject = await getPersonalProject(owner);
const folder1 = await createFolder(ownerProject, { name: 'First' });
const response = await authOwnerAgent
.get('/workflows')
.query('filter={"name":"First"}&includeFolders=true')
.expect(200);
expect(response.body).toEqual({
count: 2,
data: [
objectContaining({ id: folder1.id, name: 'First' }),
objectContaining({ id: workflow1.id, name: 'First' }),
],
});
});
test('should filter workflows and folders by field: active', async () => {
const workflow1 = await createWorkflow({ active: true }, owner);
await createWorkflow({ active: false }, owner);
const response = await authOwnerAgent
.get('/workflows')
.query('filter={ "active": true }&includeFolders=true')
.expect(200);
expect(response.body).toEqual({
count: 1,
data: [objectContaining({ id: workflow1.id, active: true })],
});
});
test('should filter workflows and folders by field: tags (AND operator)', async () => {
const baseDate = DateTime.now();
const workflow1 = await createWorkflow(
{ name: 'First', updatedAt: baseDate.toJSDate() },
owner,
);
const workflow2 = await createWorkflow(
{ name: 'Second', updatedAt: baseDate.toJSDate() },
owner,
);
const ownerProject = await getPersonalProject(owner);
const tagA = await createTag(
{
name: 'A',
},
workflow1,
);
const tagB = await createTag(
{
name: 'B',
},
workflow1,
);
await createTag({ name: 'C' }, workflow2);
await createFolder(ownerProject, {
name: 'First Folder',
tags: [tagA, tagB],
updatedAt: baseDate.plus({ minutes: 2 }).toJSDate(),
});
const response = await authOwnerAgent
.get('/workflows')
.query('filter={ "tags": ["A", "B"] }&includeFolders=true')
.expect(200);
expect(response.body).toEqual({
count: 2,
data: [
objectContaining({
name: 'First Folder',
tags: expect.arrayContaining([
{ id: any(String), name: 'A' },
{ id: any(String), name: 'B' },
]),
}),
objectContaining({
name: 'First',
tags: expect.arrayContaining([
{ id: any(String), name: 'A' },
{ id: any(String), name: 'B' },
]),
}),
],
});
});
test('should filter workflows by projectId', async () => {
const workflow = await createWorkflow({ name: 'First' }, owner);
const pp = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(owner.id);
const folder = await createFolder(pp, {
name: 'First Folder',
});
const response1 = await authOwnerAgent
.get('/workflows')
.query(`filter={ "projectId": "${pp.id}" }&includeFolders=true`)
.expect(200);
expect(response1.body.data).toHaveLength(2);
expect(response1.body.data[0].id).toBe(folder.id);
expect(response1.body.data[1].id).toBe(workflow.id);
const response2 = await authOwnerAgent
.get('/workflows')
.query('filter={ "projectId": "Non-Existing Project ID" }&includeFolders=true')
.expect(200);
expect(response2.body.data).toHaveLength(0);
});
test('should return homeProject when filtering workflows and folders by projectId', async () => {
const workflow = await createWorkflow({ name: 'First' }, owner);
await shareWorkflowWithUsers(workflow, [member]);
const pp = await getPersonalProject(member);
const folder = await createFolder(pp, {
name: 'First Folder',
});
const response = await authMemberAgent
.get('/workflows')
.query(`filter={ "projectId": "${pp.id}" }&includeFolders=true`)
.expect(200);
expect(response.body.data).toHaveLength(2);
expect(response.body.data[0].id).toBe(folder.id);
expect(response.body.data[0].homeProject).not.toBeNull();
expect(response.body.data[1].id).toBe(workflow.id);
expect(response.body.data[1].homeProject).not.toBeNull();
});
});
describe('sortBy', () => {
test('should fail when trying to sort by non sortable column', async () => {
await authOwnerAgent
.get('/workflows')
.query('sortBy=nonSortableColumn:asc&?includeFolders=true')
.expect(500);
});
test('should sort by createdAt column', async () => {
await createWorkflow({ name: 'First' }, owner);
await createWorkflow({ name: 'Second' }, owner);
const pp = await getPersonalProject(owner);
await createFolder(pp, {
name: 'First Folder',
});
await createFolder(pp, {
name: 'Z Folder',
});
let response = await authOwnerAgent
.get('/workflows')
.query('sortBy=createdAt:asc&includeFolders=true')
.expect(200);
expect(response.body).toEqual({
count: 4,
data: arrayContaining([
expect.objectContaining({ name: 'First Folder' }),
expect.objectContaining({ name: 'Z Folder' }),
expect.objectContaining({ name: 'First' }),
expect.objectContaining({ name: 'Second' }),
]),
});
response = await authOwnerAgent
.get('/workflows')
.query('sortBy=createdAt:asc&includeFolders=true')
.expect(200);
expect(response.body).toEqual({
count: 4,
data: arrayContaining([
expect.objectContaining({ name: 'Z Folder' }),
expect.objectContaining({ name: 'First Folder' }),
expect.objectContaining({ name: 'Second' }),
expect.objectContaining({ name: 'First' }),
]),
});
});
test('should sort by name column', async () => {
await createWorkflow({ name: 'a' }, owner);
await createWorkflow({ name: 'b' }, owner);
await createWorkflow({ name: 'My workflow' }, owner);
const pp = await getPersonalProject(owner);
await createFolder(pp, {
name: 'a Folder',
});
await createFolder(pp, {
name: 'Z Folder',
});
let response;
response = await authOwnerAgent
.get('/workflows')
.query('sortBy=name:asc&includeFolders=true')
.expect(200);
expect(response.body).toEqual({
count: 5,
data: [
expect.objectContaining({ name: 'a Folder' }),
expect.objectContaining({ name: 'Z Folder' }),
expect.objectContaining({ name: 'a' }),
expect.objectContaining({ name: 'b' }),
expect.objectContaining({ name: 'My workflow' }),
],
});
response = await authOwnerAgent
.get('/workflows')
.query('sortBy=name:desc&includeFolders=true')
.expect(200);
expect(response.body).toEqual({
count: 5,
data: [
expect.objectContaining({ name: 'Z Folder' }),
expect.objectContaining({ name: 'a Folder' }),
expect.objectContaining({ name: 'My workflow' }),
expect.objectContaining({ name: 'b' }),
expect.objectContaining({ name: 'a' }),
],
});
});
test('should sort by updatedAt column', async () => {
const baseDate = DateTime.now();
const pp = await getPersonalProject(owner);
await createFolder(pp, {
name: 'Folder',
});
await createFolder(pp, {
name: 'Z Folder',
});
await createWorkflow(
{ name: 'Second', updatedAt: baseDate.plus({ minutes: 1 }).toJSDate() },
owner,
);
await createWorkflow(
{ name: 'First', updatedAt: baseDate.plus({ minutes: 2 }).toJSDate() },
owner,
);
let response;
response = await authOwnerAgent
.get('/workflows')
.query('sortBy=updatedAt:asc&includeFolders=true')
.expect(200);
expect(response.body).toEqual({
count: 4,
data: arrayContaining([
expect.objectContaining({ name: 'Folder' }),
expect.objectContaining({ name: 'Z Folder' }),
expect.objectContaining({ name: 'Second' }),
expect.objectContaining({ name: 'First' }),
]),
});
response = await authOwnerAgent
.get('/workflows')
.query('sortBy=updatedAt:desc&includeFolders=true')
.expect(200);
expect(response.body).toEqual({
count: 4,
data: arrayContaining([
expect.objectContaining({ name: 'Z Folder' }),
expect.objectContaining({ name: 'Folder' }),
expect.objectContaining({ name: 'First' }),
expect.objectContaining({ name: 'Second' }),
]),
});
});
});
describe('pagination', () => {
beforeEach(async () => {
const pp = await getPersonalProject(owner);
await createWorkflow({ name: 'Workflow 1' }, owner);
await createWorkflow({ name: 'Workflow 2' }, owner);
await createWorkflow({ name: 'Workflow 3' }, owner);
await createWorkflow({ name: 'Workflow 4' }, owner);
await createWorkflow({ name: 'Workflow 5' }, owner);
await createFolder(pp, {
name: 'Folder 1',
});
});
test('should fail when skip is provided without take', async () => {
await authOwnerAgent.get('/workflows?includeFolders=true').query('skip=2').expect(500);
});
test('should handle skip with take parameter', async () => {
const response = await authOwnerAgent
.get('/workflows')
.query('skip=2&take=4&includeFolders=true');
expect(response.body.data).toHaveLength(4);
expect(response.body.count).toBe(6);
expect(response.body.data[0].name).toBe('Workflow 2');
expect(response.body.data[1].name).toBe('Workflow 3');
expect(response.body.data[2].name).toBe('Workflow 4');
expect(response.body.data[3].name).toBe('Workflow 5');
});
test('should handle pagination with sorting', async () => {
const response = await authOwnerAgent
.get('/workflows')
.query('skip=1&take=2&sortBy=name:desc&includeFolders=true');
expect(response.body.data).toHaveLength(2);
expect(response.body.count).toBe(6);
expect(response.body.data[0].name).toBe('Workflow 5');
expect(response.body.data[1].name).toBe('Workflow 4');
});
test('should handle pagination with filtering', async () => {
const pp = await getPersonalProject(owner);
await createWorkflow({ name: 'Special Workflow 1' }, owner);
await createWorkflow({ name: 'Special Workflow 2' }, owner);
await createWorkflow({ name: 'Special Workflow 3' }, owner);
await createFolder(pp, {
name: 'Special Folder 1',
});
const response = await authOwnerAgent
.get('/workflows')
.query('take=2&skip=1')
.query('filter={"name":"Special"}&includeFolders=true')
.expect(200);
expect(response.body.data).toHaveLength(2);
expect(response.body.count).toBe(4);
expect(response.body.data[0].name).toBe('Special Workflow 1');
expect(response.body.data[1].name).toBe('Special Workflow 2');
});
test('should return empty array when pagination exceeds total count', async () => {
const response = await authOwnerAgent
.get('/workflows')
.query('take=2&skip=10&includeFolders=true')
.expect(200);
expect(response.body.data).toHaveLength(0);
expect(response.body.count).toBe(6);
});
test('should return all results when no pagination parameters are provided', async () => {
const response = await authOwnerAgent
.get('/workflows')
.query('includeFolders=true')
.expect(200);
expect(response.body.data).toHaveLength(6);
expect(response.body.count).toBe(6);
});
});
});
describe('PATCH /workflows/:workflowId', () => {
test('should create workflow history version when licensed', async () => {
license.enable('feat:workflowHistory');