From cade309d3b972bb7be364c42869d2cd11a0e121a Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Fri, 11 Apr 2025 19:17:28 -0400 Subject: [PATCH] feat: Add nested search in folders (#14372) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Milorad FIlipović --- cypress/e2e/49-folders.cy.ts | 31 ++++- packages/cli/src/databases/entities/folder.ts | 3 + .../__tests__/folder.repository.test.ts | 1 + .../repositories/folder.repository.ts | 45 +++++++- .../repositories/workflow.repository.ts | 31 ++++- .../workflows/workflows.controller.test.ts | 63 ++++++++++- packages/frontend/editor-ui/src/Interface.ts | 1 + .../src/components/Folders/FolderCard.vue | 106 +++++++++++++++++- .../src/components/WorkflowCard.test.ts | 8 +- .../editor-ui/src/components/WorkflowCard.vue | 21 +++- .../layouts/ResourcesListLayout.vue | 15 ++- .../editor-ui/src/stores/folders.store.ts | 61 ++++++---- .../editor-ui/src/views/WorkflowsView.vue | 14 ++- 13 files changed, 354 insertions(+), 46 deletions(-) diff --git a/cypress/e2e/49-folders.cy.ts b/cypress/e2e/49-folders.cy.ts index 1cf4f17d85..5b927508ec 100644 --- a/cypress/e2e/49-folders.cy.ts +++ b/cypress/e2e/49-folders.cy.ts @@ -490,9 +490,9 @@ describe('Folders', () => { }); }); - describe('Workflow card breadcrumbs', () => { - it('should correctly show workflow card breadcrumbs', () => { - createNewProject('Test workflow breadcrumbs', { openAfterCreate: true }); + describe('Card breadcrumbs', () => { + it('should correctly show workflow card breadcrumbs in overview page', () => { + createNewProject('Test card breadcrumbs', { openAfterCreate: true }); createFolderFromProjectHeader('Parent Folder'); createFolderInsideFolder('Child Folder', 'Parent Folder'); getFolderCard('Child Folder').click(); @@ -508,8 +508,31 @@ describe('Folders', () => { cy.get('[role=tooltip]').should('exist'); cy.get('[role=tooltip]').should( 'contain.text', - 'est workflow breadcrumbs / Parent Folder / Child Folder / Child Folder 2', + 'Test card breadcrumbs / Parent Folder / Child Folder / Child Folder 2', ); }); + + it('should correctly toggle folder and workflow card breadcrumbs in projects and folders', () => { + createNewProject('Test nested search', { openAfterCreate: true }); + createFolderFromProjectHeader('Parent Folder'); + getFolderCard('Parent Folder').click(); + createWorkflowFromEmptyState('Child - Workflow'); + getProjectMenuItem('Test nested search').click(); + createFolderInsideFolder('Child Folder', 'Parent Folder'); + // Should not show breadcrumbs in the folder if there is no search term + cy.getByTestId('card-badge').should('not.exist'); + // Back to project root + getHomeProjectBreadcrumb().click(); + // Should not show breadcrumbs in the project if there is no search term + cy.getByTestId('card-badge').should('not.exist'); + // Search for something + cy.getByTestId('resources-list-search').type('child', { delay: 20 }); + // Both folder and workflow from child folder should be in the results - nested search works + getFolderCards().should('have.length', 1); + getWorkflowCards().should('have.length', 1); + // Card badges with breadcrumbs should be shown + getFolderCard('Child Folder').findChildByTestId('card-badge').should('exist'); + getWorkflowCard('Child - Workflow').findChildByTestId('card-badge').should('exist'); + }); }); }); diff --git a/packages/cli/src/databases/entities/folder.ts b/packages/cli/src/databases/entities/folder.ts index 7a599b7048..a5db09ff28 100644 --- a/packages/cli/src/databases/entities/folder.ts +++ b/packages/cli/src/databases/entities/folder.ts @@ -23,6 +23,9 @@ export class Folder extends WithTimestampsAndStringId { @Column() name: string; + @Column({ nullable: true, select: false }) + parentFolderId: string | null; + @ManyToOne(() => Folder, { nullable: true, onDelete: 'CASCADE' }) @JoinColumn({ name: 'parentFolderId' }) parentFolder: Folder | null; diff --git a/packages/cli/src/databases/repositories/__tests__/folder.repository.test.ts b/packages/cli/src/databases/repositories/__tests__/folder.repository.test.ts index beafdf8a02..7aaef66dc7 100644 --- a/packages/cli/src/databases/repositories/__tests__/folder.repository.test.ts +++ b/packages/cli/src/databases/repositories/__tests__/folder.repository.test.ts @@ -345,6 +345,7 @@ describe('FolderRepository', () => { expect(childFolder?.parentFolder).toEqual({ id: expect.any(String), name: 'Parent Folder', + parentFolderId: null, }); }); diff --git a/packages/cli/src/databases/repositories/folder.repository.ts b/packages/cli/src/databases/repositories/folder.repository.ts index 155cde4766..209942082c 100644 --- a/packages/cli/src/databases/repositories/folder.repository.ts +++ b/packages/cli/src/databases/repositories/folder.repository.ts @@ -125,7 +125,7 @@ export class FolderRepository extends Repository { + // Start with direct children as the base case + const baseQuery = this.createQueryBuilder('f') + .select('f.id', 'id') + .where('f.parentFolderId = :parentFolderId', { parentFolderId }); + + // Add project filter if provided + if (projectId) { + baseQuery.andWhere('f.projectId = :projectId', { projectId }); + } + + // Create the recursive query for descendants + const recursiveQuery = this.createQueryBuilder('child') + .select('child.id', 'id') + .innerJoin('folder_tree', 'parent', 'child.parentFolderId = parent.id'); + + // Add project filter if provided + if (projectId) { + recursiveQuery.andWhere('child.projectId = :projectId', { projectId }); + } + + // Create the main query with CTE + const query = this.createQueryBuilder() + .addCommonTableExpression( + `${baseQuery.getQuery()} UNION ALL ${recursiveQuery.getQuery()}`, + 'folder_tree', + { recursive: true }, + ) + .select('DISTINCT tree.id', 'id') + .from('folder_tree', 'tree') + .setParameters(baseQuery.getParameters()); + + // Execute the query and extract IDs + const result = await query.getRawMany<{ id: string }>(); + return result.map((row) => row.id); + } } diff --git a/packages/cli/src/databases/repositories/workflow.repository.ts b/packages/cli/src/databases/repositories/workflow.repository.ts index 9715ee2f8f..4e10b18eab 100644 --- a/packages/cli/src/databases/repositories/workflow.repository.ts +++ b/packages/cli/src/databases/repositories/workflow.repository.ts @@ -264,6 +264,23 @@ export class WorkflowRepository extends Repository { } async getWorkflowsAndFoldersWithCount(workflowIds: string[], options: ListQuery.Options = {}) { + if ( + options.filter?.parentFolderId && + typeof options.filter?.parentFolderId === 'string' && + options.filter.parentFolderId !== PROJECT_ROOT && + typeof options.filter?.projectId === 'string' && + options.filter.name + ) { + const folderIds = await this.folderRepository.getAllFolderIdsInHierarchy( + options.filter.parentFolderId, + options.filter.projectId, + ); + + options.filter.parentFolderIds = [options.filter.parentFolderId, ...folderIds]; + options.filter.folderIds = folderIds; + delete options.filter.parentFolderId; + } + const [workflowsAndFolders, count] = await Promise.all([ this.getWorkflowsAndFoldersUnion(workflowIds, options), this.getWorkflowsAndFoldersCount(workflowIds, options), @@ -402,6 +419,14 @@ export class WorkflowRepository extends Repository { qb.andWhere('workflow.parentFolderId = :parentFolderId', { parentFolderId: filter.parentFolderId, }); + } else if ( + filter?.parentFolderIds && + Array.isArray(filter.parentFolderIds) && + filter.parentFolderIds.length > 0 + ) { + qb.andWhere('workflow.parentFolderId IN (:...parentFolderIds)', { + parentFolderIds: filter.parentFolderIds, + }); } } @@ -519,7 +544,11 @@ export class WorkflowRepository extends Repository { const isParentFolderIncluded = isDefaultSelect || select?.parentFolder; if (isParentFolderIncluded) { - qb.leftJoinAndSelect('workflow.parentFolder', 'parentFolder'); + qb.leftJoin('workflow.parentFolder', 'parentFolder').addSelect([ + 'parentFolder.id', + 'parentFolder.name', + 'parentFolder.parentFolderId', + ]); } if (areTagsEnabled && areTagsRequested) { diff --git a/packages/cli/test/integration/workflows/workflows.controller.test.ts b/packages/cli/test/integration/workflows/workflows.controller.test.ts index 043b958d4a..ad14a40f49 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.test.ts @@ -1056,8 +1056,7 @@ describe('GET /workflows', () => { parentFolder: { id: folder.id, name: folder.name, - createdAt: expect.any(String), - updatedAt: expect.any(String), + parentFolderId: null, }, }, { @@ -1615,6 +1614,66 @@ describe('GET /workflows?includeFolders=true', () => { expect(response2.body.data).toHaveLength(0); }); + test('should filter workflows by parentFolderId and its descendants when filtering by name', async () => { + const pp = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(owner.id); + + await createFolder(pp, { + name: 'Root Folder 1', + }); + + const rootFolder2 = await createFolder(pp, { + name: 'Root Folder 2', + }); + + await createFolder(pp, { + name: 'Root Folder 3', + }); + + const subfolder1 = await createFolder(pp, { + name: 'Root folder 2 subfolder 1 key', + parentFolder: rootFolder2, + }); + + await createWorkflow( + { + name: 'Workflow 1 key', + parentFolder: rootFolder2, + }, + pp, + ); + + await createWorkflow( + { + name: 'workflow 2 key', + parentFolder: rootFolder2, + }, + pp, + ); + + await createWorkflow( + { + name: 'workflow 3 key', + parentFolder: subfolder1, + }, + pp, + ); + + const filter2Response = await authOwnerAgent + .get('/workflows') + .query( + `filter={ "projectId": "${pp.id}", "parentFolderId": "${rootFolder2.id}", "name": "key" }&includeFolders=true`, + ); + + expect(filter2Response.body.count).toBe(4); + expect(filter2Response.body.data).toHaveLength(4); + expect( + filter2Response.body.data.filter((w: WorkflowFolderUnionFull) => w.resource === 'workflow'), + ).toHaveLength(3); + expect( + filter2Response.body.data.filter((w: WorkflowFolderUnionFull) => w.resource === 'folder'), + ).toHaveLength(1); + }); + test('should return homeProject when filtering workflows and folders by projectId', async () => { const workflow = await createWorkflow({ name: 'First' }, owner); await shareWorkflowWithUsers(workflow, [member]); diff --git a/packages/frontend/editor-ui/src/Interface.ts b/packages/frontend/editor-ui/src/Interface.ts index a3b1bbf7eb..e713d48b62 100644 --- a/packages/frontend/editor-ui/src/Interface.ts +++ b/packages/frontend/editor-ui/src/Interface.ts @@ -348,6 +348,7 @@ export type FolderShortInfo = { id: string; name: string; parentFolder?: string; + parentFolderId?: string | null; }; export type BaseFolderItem = BaseResource & { diff --git a/packages/frontend/editor-ui/src/components/Folders/FolderCard.vue b/packages/frontend/editor-ui/src/components/Folders/FolderCard.vue index 38e93fcd8c..83efab32fd 100644 --- a/packages/frontend/editor-ui/src/components/Folders/FolderCard.vue +++ b/packages/frontend/editor-ui/src/components/Folders/FolderCard.vue @@ -1,41 +1,80 @@