From b9677ec6b6eb970e40e4f287e15b90e532afba63 Mon Sep 17 00:00:00 2001 From: Dominik Iwanek <141320833+dominikiwanekhyland@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:25:42 +0200 Subject: [PATCH] [ACS-11909] [ADW] Link to file or folder plan for Type filter and Delete operation (#5272) --- app/src/app.config.json | 4 + projects/aca-content/assets/i18n/en.json | 9 +- .../components/files/files.component.spec.ts | 46 +++++++++- .../lib/components/files/files.component.ts | 18 +++- .../content-management.service.spec.ts | 88 +++++++++++++++++++ .../services/content-management.service.ts | 34 ++++++- .../lib/store/effects/node.effects.spec.ts | 4 - .../src/lib/store/effects/node.effects.ts | 2 - 8 files changed, 193 insertions(+), 12 deletions(-) diff --git a/app/src/app.config.json b/app/src/app.config.json index 53b185707..6513517de 100644 --- a/app/src/app.config.json +++ b/app/src/app.config.json @@ -185,6 +185,10 @@ { "name": "SEARCH.SEARCH_HEADER.FILTERS.TYPE.DOCUMENT", "value": "TYPE:'cm:content'" + }, + { + "name": "SEARCH.SEARCH_HEADER.FILTERS.TYPE.LINK", + "value": "TYPE:'app:filelink' OR TYPE:'app:folderlink'" } ] } diff --git a/projects/aca-content/assets/i18n/en.json b/projects/aca-content/assets/i18n/en.json index ae9adb226..f0bb99139 100644 --- a/projects/aca-content/assets/i18n/en.json +++ b/projects/aca-content/assets/i18n/en.json @@ -331,6 +331,12 @@ "YES_LABEL": "Delete", "NO_LABEL": "Keep" }, + "CONFIRM_DELETE_LINK": { + "TITLE": "Delete link?", + "MESSAGE": "Links are permanently deleted and can't be restored from the trash.", + "YES_LABEL": "Delete", + "NO_LABEL": "Cancel" + }, "CONFIRM_LEAVE": { "TITLE": "Leave this library?", "MESSAGE": "Leaving will remove your access.", @@ -678,7 +684,8 @@ "TYPE": { "TITLE": "Check type", "FOLDER": "Folder", - "DOCUMENT": "Document" + "DOCUMENT": "Document", + "LINK": "Link" }, "SIZE": { "TITLE": "Content Size (range)", diff --git a/projects/aca-content/src/lib/components/files/files.component.spec.ts b/projects/aca-content/src/lib/components/files/files.component.spec.ts index 04b7e79ff..7d8e05acb 100644 --- a/projects/aca-content/src/lib/components/files/files.component.spec.ts +++ b/projects/aca-content/src/lib/components/files/files.component.spec.ts @@ -58,6 +58,7 @@ describe('FilesComponent', () => { let store: MockStore; let contentApi: ContentApiService; let route: ActivatedRoute; + let documentListService: DocumentListService; let router: any = { url: '', navigate: jasmine.createSpy('navigate'), @@ -111,7 +112,7 @@ describe('FilesComponent', () => { fixture = TestBed.createComponent(FilesComponent); component = fixture.componentInstance; - const documentListService: DocumentListService = TestBed.inject(DocumentListService); + documentListService = TestBed.inject(DocumentListService); const fakeNodeEntry: NodeEntry = { entry: { id: 'fake-node-entry' } } as NodeEntry; const fakeNodePaging: NodePaging = { list: { pagination: { count: 10, maxItems: 10, skipCount: 0 } } }; const documentLoaderNode = { children: fakeNodePaging, currentNode: fakeNodeEntry }; @@ -137,6 +138,10 @@ describe('FilesComponent', () => { spyContent.and.returnValue(of({ entry: node })); }); + afterEach(() => { + fixture.destroy(); + }); + describe('Current page is valid', () => { beforeEach(() => { fixture.detectChanges(); @@ -251,6 +256,8 @@ describe('FilesComponent', () => { beforeEach(() => { spyOn(component, 'reload'); spyOn(component, 'reloadWithoutResettingSelection'); + router.navigate['calls'].reset(); + spyOn(searchHeaderQueryBuilderService, 'execute').and.returnValue(Promise.resolve()); fixture.detectChanges(); spyOn(component.documentList, 'loadFolder').and.callFake(() => {}); @@ -296,6 +303,43 @@ describe('FilesComponent', () => { expect(component.reload).not.toHaveBeenCalled(); }); + it('should execute filter query when contentLinked emits and filters are active', () => { + const nodes = [{ entry: { parentId: 'different-id' } }] as NodeEntry[]; + + component.node = { id: '1' } as Node; + component.isFilterHeaderActive = true; + component.queryParams = { checkList: 'TYPE:"app:filelink"' }; + + nodeActionsService.contentLinked.next({ succeeded: nodes, failed: [] }); + + expect(searchHeaderQueryBuilderService.execute).toHaveBeenCalled(); + expect(component.reload).not.toHaveBeenCalled(); + }); + + it('should call regular reload when contentLinked emits and filters are not active', () => { + const nodes = [{ entry: { parentId: '1' } }] as NodeEntry[]; + + component.node = { id: '1' } as Node; + component.isFilterHeaderActive = false; + component.queryParams = null; + + nodeActionsService.contentLinked.next({ succeeded: nodes, failed: [] }); + + expect(component.reload).toHaveBeenCalled(); + }); + + it('should call regular reload when contentLinked emits and isFilterHeaderActive is true but queryParams is null', () => { + const nodes = [{ entry: { parentId: '1' } }] as NodeEntry[]; + + component.node = { id: '1' } as Node; + component.isFilterHeaderActive = true; + component.queryParams = null; + + nodeActionsService.contentLinked.next({ succeeded: nodes, failed: [] }); + + expect(component.reload).toHaveBeenCalled(); + }); + it('should call reloadWithoutResettingSelection on fileUploadComplete event if parent node match', fakeAsync(() => { const file = { file: { options: { parentId: 'parentId' } } } as FileUploadCompleteEvent; component.node = { id: 'parentId' } as Node; diff --git a/projects/aca-content/src/lib/components/files/files.component.ts b/projects/aca-content/src/lib/components/files/files.component.ts index 42cd280f4..371982e79 100644 --- a/projects/aca-content/src/lib/components/files/files.component.ts +++ b/projects/aca-content/src/lib/components/files/files.component.ts @@ -266,7 +266,15 @@ export class FilesComponent extends PageComponent implements OnInit, OnDestroy { this.navigate(route.id); } + isFilterActive(): boolean { + return this.isFilterHeaderActive && !!this.queryParams; + } + onFileUploadedEvent(event: FileUploadEvent) { + if (this.isFilterActive()) { + this.queryBuilderService.execute(); + } + const node: NodeEntry = event.file.data; // check root and child nodes @@ -307,9 +315,13 @@ export class FilesComponent extends PageComponent implements OnInit, OnDestroy { } onContentAdded(nodes: NodeEntry[]) { - const newNode = nodes.find((node) => node?.entry?.parentId === this.getParentNodeId()); - if (newNode) { - this.reload(this.selectedNode); + if (this.isFilterActive()) { + this.queryBuilderService.execute(); + } else { + const newNode = nodes.find((node) => node?.entry?.parentId === this.getParentNodeId()); + if (newNode) { + this.reload(this.selectedNode); + } } } diff --git a/projects/aca-content/src/lib/services/content-management.service.spec.ts b/projects/aca-content/src/lib/services/content-management.service.spec.ts index 0a8bf9490..8029c9b8d 100644 --- a/projects/aca-content/src/lib/services/content-management.service.spec.ts +++ b/projects/aca-content/src/lib/services/content-management.service.spec.ts @@ -37,6 +37,7 @@ import { RestoreDeletedNodesAction, SetSelectedNodesAction, ShareNodeAction, + ShowLoaderAction, UnlockWriteAction, ViewNodeVersionAction } from '@alfresco/aca-shared/store'; @@ -917,6 +918,93 @@ describe('ContentManagementService', () => { expect(document.querySelector).toHaveBeenCalledWith('.some-button'); expect(mockElement.focus).toHaveBeenCalled(); })); + + describe('link nodes', () => { + it('should open a confirmation dialog when the selection contains a link', () => { + const dialogOpenSpy = spyOn(dialog, 'open').and.returnValue({ + afterClosed: () => of(false) + } as MatDialogRef); + const selection = [{ entry: { id: '1', name: 'link.url', nodeType: 'app:filelink' } }] as NodeEntry[]; + + store.dispatch(new DeleteNodesAction(selection)); + + expect(dialogOpenSpy).toHaveBeenCalledWith( + ConfirmDialogComponent, + jasmine.objectContaining({ + data: jasmine.objectContaining({ title: 'APP.DIALOGS.CONFIRM_DELETE_LINK.TITLE' }) + }) + ); + }); + + it('should delete the link when the confirmation dialog is accepted', () => { + spyOn(dialog, 'open').and.returnValue({ + afterClosed: () => of(true) + } as MatDialogRef); + const deleteNodeSpy = spyOn(contentApi, 'deleteNode').and.returnValue(of(null)); + const selection = [{ entry: { id: '1', name: 'link.url', nodeType: 'app:filelink' } }] as NodeEntry[]; + + store.dispatch(new DeleteNodesAction(selection)); + + expect(deleteNodeSpy).toHaveBeenCalledWith('1'); + }); + + it('should not delete the link when the confirmation dialog is cancelled', () => { + spyOn(dialog, 'open').and.returnValue({ + afterClosed: () => of(false) + } as MatDialogRef); + const deleteNodeSpy = spyOn(contentApi, 'deleteNode').and.returnValue(of(null)); + const selection = [{ entry: { id: '1', name: 'link.url', nodeType: 'app:filelink' } }] as NodeEntry[]; + + store.dispatch(new DeleteNodesAction(selection)); + + expect(deleteNodeSpy).not.toHaveBeenCalled(); + }); + + it('should not raise the confirmation dialog for regular nodes', () => { + const dialogOpenSpy = spyOn(dialog, 'open'); + spyOn(contentApi, 'deleteNode').and.returnValue(of(null)); + const selection = [{ entry: { id: '1', name: 'name1', nodeType: 'cm:content' } }] as NodeEntry[]; + + store.dispatch(new DeleteNodesAction(selection)); + + expect(dialogOpenSpy).not.toHaveBeenCalled(); + }); + + it('should not offer undo when every deleted node is a link', () => { + spyOn(dialog, 'open').and.returnValue({ + afterClosed: () => of(true) + } as MatDialogRef); + spyOn(contentApi, 'deleteNode').and.returnValue(of(null)); + const selection = [{ entry: { id: '1', name: 'link.url', nodeType: 'app:filelink' } }] as NodeEntry[]; + + store.dispatch(new DeleteNodesAction(selection)); + + expect(openSnackMessageActionSpy.calls.argsFor(0)[1]).toBeNull(); + }); + + it('should dispatch ShowLoaderAction(true) when link deletion is confirmed', () => { + const dispatchSpy = spyOn(store, 'dispatch'); + spyOn(dialog, 'open').and.returnValue({ + afterClosed: () => of(true) + } as MatDialogRef); + spyOn(contentApi, 'deleteNode').and.returnValue(of(null)); + const selection = [{ entry: { id: '1', name: 'link.url', nodeType: 'app:filelink' } }] as NodeEntry[]; + + contentManagementService.deleteNodes(selection); + + expect(dispatchSpy).toHaveBeenCalledWith(jasmine.any(ShowLoaderAction)); + }); + + it('should dispatch ShowLoaderAction(true) for regular node deletion', () => { + const dispatchSpy = spyOn(store, 'dispatch'); + spyOn(contentApi, 'deleteNode').and.returnValue(of(null)); + const selection = [{ entry: { id: '1', name: 'name1', nodeType: 'cm:content' } }] as NodeEntry[]; + + contentManagementService.deleteNodes(selection); + + expect(dispatchSpy).toHaveBeenCalledWith(jasmine.any(ShowLoaderAction)); + }); + }); }); describe('Permanent Delete', () => { diff --git a/projects/aca-content/src/lib/services/content-management.service.ts b/projects/aca-content/src/lib/services/content-management.service.ts index 1ddf3b655..b3d362f95 100644 --- a/projects/aca-content/src/lib/services/content-management.service.ts +++ b/projects/aca-content/src/lib/services/content-management.service.ts @@ -737,8 +737,40 @@ export class ContentManagementService { } deleteNodes(items: NodeEntry[], allowUndo = true, focusedElementOnCloseSelector?: string): void { + const containsLink = items.some((node) => this.isLinkNode(node)); + + if (containsLink) { + const dialogRef = this.dialogRef.open(ConfirmDialogComponent, { + data: { + title: 'APP.DIALOGS.CONFIRM_DELETE_LINK.TITLE', + message: 'APP.DIALOGS.CONFIRM_DELETE_LINK.MESSAGE', + yesLabel: 'APP.DIALOGS.CONFIRM_DELETE_LINK.YES_LABEL', + noLabel: 'APP.DIALOGS.CONFIRM_DELETE_LINK.NO_LABEL' + }, + minWidth: '250px' + }); + + dialogRef.afterClosed().subscribe((result) => { + if (result === true) { + this.store.dispatch(new ShowLoaderAction(true)); + this.deleteNodesBatch(items, allowUndo, focusedElementOnCloseSelector); + } else { + this.focusAfterClose(focusedElementOnCloseSelector); + } + }); + } else { + this.store.dispatch(new ShowLoaderAction(true)); + this.deleteNodesBatch(items, allowUndo, focusedElementOnCloseSelector); + } + } + + private isLinkNode(node: NodeEntry): boolean { + return node.entry.nodeType === 'app:filelink' || node.entry.nodeType === 'app:folderlink'; + } + + private deleteNodesBatch(items: NodeEntry[], allowUndo = true, focusedElementOnCloseSelector?: string): void { this.focusAfterClose(focusedElementOnCloseSelector); - const canUndo = allowUndo && !items.every((node) => node.entry.nodeType === 'app:filelink' || node.entry.nodeType === 'app:folderlink'); + const canUndo = allowUndo && !items.every((node) => this.isLinkNode(node)); const batch: Observable[] = []; items.forEach((node) => { diff --git a/projects/aca-content/src/lib/store/effects/node.effects.spec.ts b/projects/aca-content/src/lib/store/effects/node.effects.spec.ts index d25b80eb5..a44274aa5 100644 --- a/projects/aca-content/src/lib/store/effects/node.effects.spec.ts +++ b/projects/aca-content/src/lib/store/effects/node.effects.spec.ts @@ -49,7 +49,6 @@ import { SetInfoDrawerStateAction, SetSelectedNodesAction, ShareNodeAction, - ShowLoaderAction, UndoDeleteNodesAction, UnlockWriteAction, UnshareNodesAction, @@ -223,7 +222,6 @@ describe('NodeEffects', () => { store.dispatch(new DeleteNodesAction([node])); expect(store.dispatch).toHaveBeenCalledWith(jasmine.objectContaining({ ...new DeleteNodesAction([node], true) })); - expect(store.dispatch).toHaveBeenCalledWith(jasmine.objectContaining({ ...new ShowLoaderAction(true) })); expect(contentService.deleteNodes).toHaveBeenCalledWith([node], true, undefined); }); @@ -240,7 +238,6 @@ describe('NodeEffects', () => { expect(store.dispatch).toHaveBeenCalledWith( jasmine.objectContaining({ ...new DeleteNodesAction(null, true, { focusedElementOnCloseSelector: '.test-selector' }) }) ); - expect(store.dispatch).toHaveBeenCalledWith(jasmine.objectContaining({ ...new ShowLoaderAction(true) })); expect(contentService.deleteNodes).toHaveBeenCalledWith([node], true, '.test-selector'); })); @@ -250,7 +247,6 @@ describe('NodeEffects', () => { store.dispatch(new DeleteNodesAction(null)); expect(store.dispatch).toHaveBeenCalledWith(jasmine.objectContaining({ ...new DeleteNodesAction(null) })); - expect(store.dispatch).toHaveBeenCalledWith(jasmine.objectContaining({ ...new ShowLoaderAction(true) })); expect(contentService.deleteNodes).not.toHaveBeenCalled(); }); }); diff --git a/projects/aca-content/src/lib/store/effects/node.effects.ts b/projects/aca-content/src/lib/store/effects/node.effects.ts index 9b4e8beba..e39629d23 100644 --- a/projects/aca-content/src/lib/store/effects/node.effects.ts +++ b/projects/aca-content/src/lib/store/effects/node.effects.ts @@ -52,7 +52,6 @@ import { RestoreDeletedNodesAction, SetInfoDrawerStateAction, ShareNodeAction, - ShowLoaderAction, UndoDeleteNodesAction, UnlockWriteAction, UnshareNodesAction @@ -166,7 +165,6 @@ export class NodeEffects { this.actions$.pipe( ofType(NodeActionTypes.Delete), map((action) => { - this.store.dispatch(new ShowLoaderAction(true)); if (action?.payload?.length > 0) { this.contentService.deleteNodes(action.payload, action.allowUndo, action.configuration?.focusedElementOnCloseSelector); } else {