mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
feat(core): Introduce new query string parameter includeFolders
to GET /workflows
endpoint (no-changelog) (#13352)
This commit is contained in:
parent
243042f217
commit
c3f111275b
|
@ -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[];
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ export class WorkflowSelect extends BaseSelect {
|
|||
'updatedAt',
|
||||
'versionId',
|
||||
'ownedBy', // non-entity field
|
||||
'parentFolder',
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -212,6 +212,7 @@ export class WorkflowsController {
|
|||
req.user,
|
||||
req.listQueryOptions,
|
||||
!!req.query.includeScopes,
|
||||
!!req.query.includeFolders,
|
||||
);
|
||||
|
||||
res.json({ count, data });
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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');
|
||||
|
|
Loading…
Reference in a new issue