diff --git a/projects/aca-content/src/lib/aca-content.routes.ts b/projects/aca-content/src/lib/aca-content.routes.ts index 11d873609..97deadfa2 100644 --- a/projects/aca-content/src/lib/aca-content.routes.ts +++ b/projects/aca-content/src/lib/aca-content.routes.ts @@ -181,6 +181,26 @@ export const CONTENT_LAYOUT_ROUTES: Route[] = [ defaultNodeId: '-root-' } }, + { + path: 'details/:nodeId', + children: [ + { + path: '', + component: DetailsComponent, + data: { + navigateSource: 'repository' + } + }, + { + path: ':activeTab', + component: DetailsComponent, + data: { + title: 'APP.BROWSE.PERSONAL.PERMISSIONS.TITLE', + navigateSource: 'repository' + } + } + ] + }, ...createViewRoutes('repository') ] }, diff --git a/projects/aca-content/src/lib/components/common/location-link/location-link.component.spec.ts b/projects/aca-content/src/lib/components/common/location-link/location-link.component.spec.ts index fa6c3ec7e..cf77fa51c 100644 --- a/projects/aca-content/src/lib/components/common/location-link/location-link.component.spec.ts +++ b/projects/aca-content/src/lib/components/common/location-link/location-link.component.spec.ts @@ -116,7 +116,7 @@ describe('LocationLinkComponent', () => { it('should display primary path', () => { fixture.detectChanges(); - expect(getCellText()).toBe('APP.BROWSE.PERSONAL.TITLE'); + expect(getCellText()).toBe('APP.BROWSE.REPOSITORY_VIEW.TITLE'); }); it('should display path name when showLocation is true', () => { @@ -126,13 +126,13 @@ describe('LocationLinkComponent', () => { expect(getCellText()).toBe('Company Home'); }); - it('should display APP.BROWSE.PERSONAL.TITLE for single Company Home element', () => { + it('should display APP.BROWSE.REPOSITORY_VIEW.TITLE for single Company Home element', () => { fixture.componentInstance.context.row.node.entry.path = { name: 'Test', elements: [{ id: '1', name: 'Company Home' }] }; fixture.detectChanges(); - expect(getCellText()).toBe('APP.BROWSE.PERSONAL.TITLE'); + expect(getCellText()).toBe('APP.BROWSE.REPOSITORY_VIEW.TITLE'); }); it('should not display APP.BROWSE.PERSONAL.TITLE when single element is not Company Home', () => { @@ -349,7 +349,7 @@ describe('LocationLinkComponent', () => { }); it('should display tooltip on mouse enter', (done) => { - testTooltipValue('APP.BROWSE.PERSONAL.TITLE', done); + testTooltipValue('APP.BROWSE.REPOSITORY_VIEW.TITLE', done); }); it('should join elements with slash for the tooltip when first element is not Company Home', (done) => { @@ -365,7 +365,7 @@ describe('LocationLinkComponent', () => { testTooltipValue('Root/Folder1/Folder2', done); }); - it('should replace Company Home with Personal Files', (done) => { + it('should replace Company Home with Repository for repository content', (done) => { fixture.componentInstance.context.row.node.entry.path = { name: 'Test', elements: [ @@ -374,7 +374,7 @@ describe('LocationLinkComponent', () => { ] }; - testTooltipValue('APP.BROWSE.PERSONAL.TITLE/Folder1', done); + testTooltipValue('APP.BROWSE.REPOSITORY_VIEW.TITLE/Folder1', done); }); it('should handle User Homes path correctly', (done) => { @@ -401,7 +401,7 @@ describe('LocationLinkComponent', () => { ] }; - testTooltipValue('APP.BROWSE.PERSONAL.TITLE/OtherFolder/SubFolder', done); + testTooltipValue('APP.BROWSE.REPOSITORY_VIEW.TITLE/OtherFolder/SubFolder', done); }); it('should use cm:title from node info response when available', (done) => { diff --git a/projects/aca-content/src/lib/components/common/location-link/location-link.component.ts b/projects/aca-content/src/lib/components/common/location-link/location-link.component.ts index 77e3473ef..19b00775e 100644 --- a/projects/aca-content/src/lib/components/common/location-link/location-link.component.ts +++ b/projects/aca-content/src/lib/components/common/location-link/location-link.component.ts @@ -22,11 +22,11 @@ * from Hyland Software. If not, see . */ -import { Component, Input, ChangeDetectionStrategy, OnInit, ViewEncapsulation, HostListener, inject } from '@angular/core'; -import { PathInfo, NodeEntry } from '@alfresco/js-api'; -import { Observable, BehaviorSubject, of } from 'rxjs'; +import { ChangeDetectionStrategy, Component, HostListener, inject, Input, OnInit, ViewEncapsulation } from '@angular/core'; +import { NodeEntry, PathInfo } from '@alfresco/js-api'; +import { BehaviorSubject, Observable, of } from 'rxjs'; import { Store } from '@ngrx/store'; -import { NavigateToParentFolder } from '@alfresco/aca-shared/store'; +import { getNodeContentSource, NavigateToParentFolder } from '@alfresco/aca-shared/store'; import { ContentApiService } from '@alfresco/aca-shared'; import { DialogComponent, DialogSize, TranslationService } from '@alfresco/adf-core'; import { CommonModule } from '@angular/common'; @@ -123,7 +123,7 @@ export class LocationLinkComponent implements OnInit { // for admin users if (elements.length === 1 && elements[0] === 'Company Home') { - return of('APP.BROWSE.PERSONAL.TITLE'); + return of('APP.BROWSE.REPOSITORY_VIEW.TITLE'); } // for non-admin users @@ -161,14 +161,16 @@ export class LocationLinkComponent implements OnInit { let result: string = null; + const contentSource = getNodeContentSource(path); const elements = path.elements.map((e) => { return { ...e }; }); const personalFiles = this.translationService.instant('APP.BROWSE.PERSONAL.TITLE'); const fileLibraries = this.translationService.instant('APP.BROWSE.LIBRARIES.TITLE'); + const repository = this.translationService.instant('APP.BROWSE.REPOSITORY_VIEW.TITLE'); if (elements[0].name === 'Company Home') { - elements[0].name = personalFiles; + elements[0].name = contentSource === 'repository' ? repository : personalFiles; if (elements.length > 2) { if (elements[1].name === 'Sites') { diff --git a/projects/aca-content/src/lib/services/node-actions.service.spec.ts b/projects/aca-content/src/lib/services/node-actions.service.spec.ts index b4bbd18c3..528831a3d 100644 --- a/projects/aca-content/src/lib/services/node-actions.service.spec.ts +++ b/projects/aca-content/src/lib/services/node-actions.service.spec.ts @@ -431,6 +431,32 @@ describe('NodeActionsService', () => { number: 1 }); }); + + it('should include repository destination in custom dropdown', () => { + spyOn(service, 'doBatchOperation').and.callThrough(); + spyOn(service, 'getContentNodeSelection').and.callThrough(); + spyOn(service, 'getEntryParentId').and.returnValue('parent-id'); + + let dialogData = null; + spyOn(dialog, 'open').and.callFake((_contentNodeSelectorComponent: any, data: any) => { + dialogData = data; + return { componentInstance: {}, afterClosed: of } as unknown as MatDialogRef; + }); + + service.copyNodes([fileToCopy]); + + expect(dialogData).toBeDefined(); + expect(dialogData.data.dropdownSiteList.list.entries.length).toBe(3); + + const entries = dialogData.data.dropdownSiteList.list.entries; + expect(entries[0].entry.guid).toBe('-my-'); + expect(entries[1].entry.guid).toBe('-mysites-'); + expect(entries[2].entry.guid).toBe('-root-'); + + expect(translationService.instant).toHaveBeenCalledWith('APP.BROWSE.PERSONAL.SIDENAV_LINK.LABEL'); + expect(translationService.instant).toHaveBeenCalledWith('APP.BROWSE.LIBRARIES.MENU.MY_LIBRARIES.SIDENAV_LINK.LABEL'); + expect(translationService.instant).toHaveBeenCalledWith('APP.BROWSE.REPOSITORY_VIEW.TITLE'); + }); }); describe('copyNodeAction', () => { diff --git a/projects/aca-content/src/lib/services/node-actions.service.ts b/projects/aca-content/src/lib/services/node-actions.service.ts index 23a69e0ae..1c08ddb34 100644 --- a/projects/aca-content/src/lib/services/node-actions.service.ts +++ b/projects/aca-content/src/lib/services/node-actions.service.ts @@ -22,31 +22,31 @@ * from Hyland Software. If not, see . */ -import { Injectable, inject } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; -import { Observable, Subject, of, zip, from } from 'rxjs'; +import { from, Observable, of, Subject, zip } from 'rxjs'; import { ThumbnailService, TranslationService } from '@alfresco/adf-core'; import { AlfrescoApiService, - DocumentListService, + ContentNodeDialogService, ContentNodeSelectorComponent, ContentNodeSelectorComponentData, - ContentNodeDialogService, - ShareDataRow, + ContentService, + DocumentListService, NodeAction, - ContentService + ShareDataRow } from '@alfresco/adf-content-services'; import { - NodeEntry, + LazyApi, Node, - SharedLink, - SitePaging, - NodeChildAssociationPaging, NodeChildAssociationEntry, + NodeChildAssociationPaging, + NodeEntry, NodesApi, + SharedLink, Site, - SitePagingList, - LazyApi + SitePaging, + SitePagingList } from '@alfresco/js-api'; import { ContentApiService } from '@alfresco/aca-shared'; import { catchError, map, mergeMap } from 'rxjs/operators'; @@ -232,6 +232,12 @@ export class NodeActionsService { guid: '-mysites-', title: this.translation.instant('APP.BROWSE.LIBRARIES.MENU.MY_LIBRARIES.SIDENAV_LINK.LABEL') } as Site + }, + { + entry: { + guid: '-root-', + title: this.translation.instant('APP.BROWSE.REPOSITORY_VIEW.TITLE') + } as Site } ] } as SitePagingList 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 00e1fd959..d25b80eb5 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 @@ -31,6 +31,7 @@ import { ContentManagementService } from '../../services/content-management.serv import { CopyNodesAction, CreateFolderAction, + DeletedNodeInfo, DeleteNodesAction, EditFolderAction, ExpandInfoDrawerAction, @@ -62,6 +63,7 @@ import { of } from 'rxjs'; import { MatDialogModule } from '@angular/material/dialog'; import { MatSnackBarModule } from '@angular/material/snack-bar'; import { NodeEntry, UserInfo } from '@alfresco/js-api'; +import { Node } from '@alfresco/js-api/typings/src/api/content-rest-api/model/node'; describe('NodeEffects', () => { let store: Store; @@ -91,8 +93,8 @@ describe('NodeEffects', () => { it('should share node from payload', () => { spyOn(contentService, 'shareNode').and.stub(); - const node: any = { - entry: {} + const node: NodeEntry = { + entry: {} as Node }; store.dispatch(new ShareNodeAction(node)); @@ -102,7 +104,7 @@ describe('NodeEffects', () => { it('should share node from active selection', fakeAsync(() => { spyOn(contentService, 'shareNode').and.stub(); - const node: any = { entry: { isFile: true } }; + const node: NodeEntry = { entry: { isFile: true } as Node }; store.dispatch(new SetSelectedNodesAction([node])); tick(100); @@ -124,7 +126,7 @@ describe('NodeEffects', () => { it('should unshare nodes from the payload', () => { spyOn(contentService, 'unshareNodes').and.stub(); - const node: any = {}; + const node = {} as NodeEntry; store.dispatch(new UnshareNodesAction([node])); expect(contentService.unshareNodes).toHaveBeenCalledWith([node]); @@ -133,7 +135,7 @@ describe('NodeEffects', () => { it('should unshare nodes from the active selection', fakeAsync(() => { spyOn(contentService, 'unshareNodes').and.stub(); - const node: any = { entry: { isFile: true } }; + const node = { entry: { isFile: true } } as NodeEntry; store.dispatch(new SetSelectedNodesAction([node])); tick(100); @@ -155,7 +157,7 @@ describe('NodeEffects', () => { it('should purge deleted nodes from the payload', () => { spyOn(contentService, 'purgeDeletedNodes').and.stub(); - const node: any = {}; + const node = {} as NodeEntry; store.dispatch(new PurgeDeletedNodesAction([node])); expect(contentService.purgeDeletedNodes).toHaveBeenCalledWith([node], undefined); @@ -164,7 +166,7 @@ describe('NodeEffects', () => { it('should purge nodes from the active selection', fakeAsync(() => { spyOn(contentService, 'purgeDeletedNodes').and.stub(); - const node: any = { entry: { isFile: true } }; + const node = { entry: { isFile: true } } as NodeEntry; store.dispatch(new SetSelectedNodesAction([node])); tick(100); @@ -186,7 +188,7 @@ describe('NodeEffects', () => { it('should restore deleted nodes from the payload', () => { spyOn(contentService, 'restoreDeletedNodes').and.stub(); - const node: any = {}; + const node = {} as NodeEntry; store.dispatch(new RestoreDeletedNodesAction([node])); expect(contentService.restoreDeletedNodes).toHaveBeenCalledWith([node], undefined); @@ -195,7 +197,7 @@ describe('NodeEffects', () => { it('should restore deleted nodes from the active selection', fakeAsync(() => { spyOn(contentService, 'restoreDeletedNodes').and.stub(); - const node: any = { entry: { isFile: true } }; + const node = { entry: { isFile: true } } as NodeEntry; store.dispatch(new SetSelectedNodesAction([node])); tick(100); @@ -217,7 +219,7 @@ describe('NodeEffects', () => { it('should delete nodes from the payload', () => { spyOn(contentService, 'deleteNodes').and.stub(); spyOn(store, 'dispatch').and.callThrough(); - const node: any = {}; + const node = {} as NodeEntry; store.dispatch(new DeleteNodesAction([node])); expect(store.dispatch).toHaveBeenCalledWith(jasmine.objectContaining({ ...new DeleteNodesAction([node], true) })); @@ -228,7 +230,7 @@ describe('NodeEffects', () => { it('should delete nodes from the active selection', fakeAsync(() => { spyOn(contentService, 'deleteNodes').and.stub(); spyOn(store, 'dispatch').and.callThrough(); - const node: any = { entry: { isFile: true } }; + const node = { entry: { isFile: true } } as NodeEntry; store.dispatch(new SetSelectedNodesAction([node])); tick(100); @@ -257,7 +259,7 @@ describe('NodeEffects', () => { it('should undo deleted nodes from the payload', () => { spyOn(contentService, 'undoDeleteNodes').and.stub(); - const node: any = {}; + const node = { id: 'node-id', name: 'node-name', status: 200 } as DeletedNodeInfo; store.dispatch(new UndoDeleteNodesAction([node])); expect(contentService.undoDeleteNodes).toHaveBeenCalledWith([node]); @@ -285,7 +287,7 @@ describe('NodeEffects', () => { }); it('should create folder in the active selected one', fakeAsync(() => { - const currentFolder: any = { isFolder: true, id: 'folder1' }; + const currentFolder = { isFolder: true, id: 'folder1' } as Node; store.dispatch(new SetCurrentFolderAction(currentFolder)); tick(100); @@ -299,7 +301,7 @@ describe('NodeEffects', () => { it('should edit folder from the payload', () => { spyOn(contentService, 'editFolder').and.stub(); - const node: any = { entry: { isFolder: true, id: 'folder1' } }; + const node = { entry: { isFolder: true, id: 'folder1' } } as NodeEntry; store.dispatch(new EditFolderAction(node)); expect(contentService.editFolder).toHaveBeenCalledWith(node); @@ -308,9 +310,9 @@ describe('NodeEffects', () => { it('should edit folder from the active selection', fakeAsync(() => { spyOn(contentService, 'editFolder').and.stub(); - const currentFolder: any = { + const currentFolder = { entry: { isFolder: true, isFile: false, id: 'folder1' } - }; + } as NodeEntry; store.dispatch(new SetSelectedNodesAction([currentFolder])); tick(100); @@ -332,7 +334,7 @@ describe('NodeEffects', () => { it('should copy nodes from the payload', () => { spyOn(contentService, 'copyNodes').and.stub(); - const node: any = { entry: { isFile: true } }; + const node = { entry: { isFile: true } } as NodeEntry; store.dispatch(new CopyNodesAction([node])); expect(contentService.copyNodes).toHaveBeenCalledWith([node]); @@ -341,7 +343,7 @@ describe('NodeEffects', () => { it('should copy nodes from the active selection', fakeAsync(() => { spyOn(contentService, 'copyNodes').and.stub(); - const node: any = { entry: { isFile: true } }; + const node = { entry: { isFile: true } } as NodeEntry; store.dispatch(new SetSelectedNodesAction([node])); tick(100); @@ -364,7 +366,7 @@ describe('NodeEffects', () => { it('should move nodes from the payload', () => { spyOn(contentService, 'moveNodes').and.stub(); - const node: any = { entry: { isFile: true } }; + const node = { entry: { isFile: true } } as NodeEntry; store.dispatch(new MoveNodesAction([node])); expect(contentService.moveNodes).toHaveBeenCalledWith([node]); @@ -373,7 +375,7 @@ describe('NodeEffects', () => { it('should move nodes from the active selection', fakeAsync(() => { spyOn(contentService, 'moveNodes').and.stub(); - const node: any = { entry: { isFile: true } }; + const node = { entry: { isFile: true } } as NodeEntry; store.dispatch(new SetSelectedNodesAction([node])); tick(100); @@ -459,7 +461,7 @@ describe('NodeEffects', () => { describe('managePermissions$', () => { it('should manage permissions from the payload', () => { spyOn(router, 'navigateByUrl').and.stub(); - const node: any = { entry: { isFile: true, id: 'fileId' } }; + const node = { entry: { isFile: true, id: 'fileId' } } as NodeEntry; store.dispatch(new ManagePermissionsAction(node)); expect(router.navigateByUrl).toHaveBeenCalledWith('personal-files/details/fileId/permissions'); @@ -473,6 +475,29 @@ describe('NodeEffects', () => { expect(router.navigateByUrl).toHaveBeenCalledWith('personal-files/details/fileId/permissions'); }); + it('should manage permissions via the repository route for repository nodes', () => { + spyOn(router, 'navigateByUrl').and.stub(); + const node = { + entry: { + isFile: true, + id: 'fileId', + path: { elements: [{ name: 'Company Home' }, { name: 'Some Folder' }] } + } + } as NodeEntry; + store.dispatch(new ManagePermissionsAction(node)); + + expect(router.navigateByUrl).toHaveBeenCalledWith('repository/details/fileId/permissions'); + }); + + it('should manage permissions via the repository route when on the repository view', () => { + spyOnProperty(router, 'url', 'get').and.returnValue('/repository/some-folder-id'); + spyOn(router, 'navigateByUrl').and.stub(); + const node = { entry: { isFile: true, id: 'fileId' } } as NodeEntry; + store.dispatch(new ManagePermissionsAction(node)); + + expect(router.navigateByUrl).toHaveBeenCalledWith('repository/details/fileId/permissions'); + }); + it('should do nothing if invoking manage permissions with no data', () => { spyOn(store, 'select').and.returnValue(of(null)); spyOn(router, 'navigate').and.stub(); @@ -500,9 +525,9 @@ describe('NodeEffects', () => { describe('printFile$', () => { it('it should print node content from payload', () => { spyOn(renditionViewerService, 'printFileGeneric').and.stub(); - const node: any = { + const node = { entry: { id: 'node-id', content: { mimeType: 'text/json' } } - }; + } as NodeEntry; store.dispatch(new PrintFileAction(node)); @@ -511,13 +536,13 @@ describe('NodeEffects', () => { it('it should print node content from store', fakeAsync(() => { spyOn(renditionViewerService, 'printFileGeneric').and.stub(); - const node: any = { + const node = { entry: { isFile: true, id: 'node-id', content: { mimeType: 'text/json' } } - }; + } as NodeEntry; store.dispatch(new SetSelectedNodesAction([node])); @@ -542,7 +567,7 @@ describe('NodeEffects', () => { describe('unlockWrite$', () => { it('should unlock node from payload', () => { spyOn(contentService, 'unlockNode').and.stub(); - const node: any = { entry: { id: 'node-id' } }; + const node = { entry: { id: 'node-id' } } as NodeEntry; store.dispatch(new UnlockWriteAction(node)); @@ -551,7 +576,7 @@ describe('NodeEffects', () => { it('should unlock node from store selection', fakeAsync(() => { spyOn(contentService, 'unlockNode').and.stub(); - const node: any = { entry: { isFile: true, id: 'node-id' } }; + const node = { entry: { isFile: true, id: 'node-id' } } as NodeEntry; store.dispatch(new SetSelectedNodesAction([node])); @@ -565,7 +590,7 @@ describe('NodeEffects', () => { describe('aspectList$', () => { it('should call aspect dialog', () => { - const node: any = { entry: { isFile: true } }; + const node = { entry: { isFile: true } } as NodeEntry; spyOn(contentService, 'manageAspects').and.stub(); store.dispatch(new ManageAspectsAction(node)); @@ -576,7 +601,7 @@ describe('NodeEffects', () => { it('should call aspect dialog from the active file selection', fakeAsync(() => { spyOn(contentService, 'manageAspects').and.stub(); - const node: any = { entry: { isFile: true, id: 'file-node-id' } }; + const node = { entry: { isFile: true, id: 'file-node-id' } } as NodeEntry; store.dispatch(new SetSelectedNodesAction([node])); tick(100); @@ -589,7 +614,7 @@ describe('NodeEffects', () => { it('should call aspect dialog from the active folder selection', fakeAsync(() => { spyOn(contentService, 'manageAspects').and.stub(); - const node: any = { entry: { isFile: false, id: 'folder-node-id' } }; + const node = { entry: { isFile: false, id: 'folder-node-id' } } as NodeEntry; store.dispatch(new SetSelectedNodesAction([node])); tick(100); @@ -626,13 +651,37 @@ describe('NodeEffects', () => { value: jasmine.createSpy('navigateByUrl') } }); - const node: any = { entry: { isFile: true, id: 'node-id' } }; + const node = { entry: { isFile: true, id: 'node-id' } } as NodeEntry; store.dispatch(new ExpandInfoDrawerAction(node)); expect(store.dispatch).toHaveBeenCalledWith( jasmine.objectContaining({ ...new NavigateUrlAction('personal-files/details/node-id?location=test-page') }) ); }); + + it('should redirect to repository url for repository nodes', () => { + spyOn(store, 'dispatch').and.callThrough(); + Object.defineProperties(router, { + events: { + value: of(new NavigationEnd(1, 'test/(viewer:view/node-id)', '')) + }, + navigateByUrl: { + value: jasmine.createSpy('navigateByUrl') + } + }); + const node = { + entry: { + isFile: true, + id: 'node-id', + path: { elements: [{ name: 'Company Home' }, { name: 'Some Folder' }] } + } + } as NodeEntry; + + store.dispatch(new ExpandInfoDrawerAction(node)); + expect(store.dispatch).toHaveBeenCalledWith( + jasmine.objectContaining({ ...new NavigateUrlAction('repository/details/node-id?location=test-page') }) + ); + }); }); describe('nodeInformation$', () => { 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 ca476af65..9b4e8beba 100644 --- a/projects/aca-content/src/lib/store/effects/node.effects.ts +++ b/projects/aca-content/src/lib/store/effects/node.effects.ts @@ -35,6 +35,9 @@ import { ExpandInfoDrawerAction, getAppSelection, getCurrentFolder, + getNodeContentSource, + LinkNodesAction, + LocateLinkedItemAction, ManageAspectsAction, ManagePermissionsAction, ManageRulesAction, @@ -43,6 +46,7 @@ import { NavigateRouteAction, NavigateUrlAction, NodeActionTypes, + NodeInformationAction, PrintFileAction, PurgeDeletedNodesAction, RestoreDeletedNodesAction, @@ -51,15 +55,13 @@ import { ShowLoaderAction, UndoDeleteNodesAction, UnlockWriteAction, - UnshareNodesAction, - NodeInformationAction, - LinkNodesAction, - LocateLinkedItemAction + UnshareNodesAction } from '@alfresco/aca-shared/store'; import { ContentManagementService } from '../../services/content-management.service'; import { RenditionService } from '@alfresco/adf-content-services'; import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { DomSanitizer } from '@angular/platform-browser'; +import { Node } from '@alfresco/js-api'; @Injectable() export class NodeEffects { @@ -336,7 +338,7 @@ export class NodeEffects { .pipe(first((event) => event instanceof NavigationEnd)) .subscribe(() => this.store.dispatch(new SetInfoDrawerStateAction(true))); if (action?.payload) { - const route = 'personal-files/details'; + const route = this.getDetailsRoute(action.payload.entry); this.store.dispatch(new NavigateUrlAction([route, action.payload.entry.id, 'permissions'].join('/'))); } else { this.store @@ -344,7 +346,7 @@ export class NodeEffects { .pipe(take(1)) .subscribe((selection) => { if (selection && !selection.isEmpty) { - const route = 'personal-files/details'; + const route = this.getDetailsRoute(selection.last.entry); this.store.dispatch(new NavigateUrlAction([route, selection.last.entry.id, 'permissions'].join('/'))); } }); @@ -366,9 +368,9 @@ export class NodeEffects { this.activatedRoute.queryParams.pipe(take(1)).subscribe((params) => { const location = params.location || this.router.url; const sanitizedLocation = this.sanitizer.sanitize(SecurityContext.URL, location); - const route = 'personal-files/details'; if (action?.payload) { + const route = this.getDetailsRoute(action.payload.entry, location); this.store.dispatch(new NavigateUrlAction([route, action.payload.entry.id].join('/') + `?location=${sanitizedLocation}`)); } else { this.store @@ -376,6 +378,7 @@ export class NodeEffects { .pipe(take(1)) .subscribe((selection) => { if (selection && !selection.isEmpty) { + const route = this.getDetailsRoute(selection.last.entry, location); this.store.dispatch(new NavigateUrlAction([route, selection.last.entry.id].join('/') + `?location=${sanitizedLocation}`)); } }); @@ -529,4 +532,9 @@ export class NodeEffects { ), { dispatch: false } ); + + private getDetailsRoute(entry: Node, location = this.router.url): string { + const isRepository = location?.includes('/repository') || getNodeContentSource(entry?.path) === 'repository'; + return `${isRepository ? 'repository' : 'personal-files'}/details`; + } } diff --git a/projects/aca-shared/store/src/effects/router.effects.spec.ts b/projects/aca-shared/store/src/effects/router.effects.spec.ts index 9b56f4781..c423d743a 100644 --- a/projects/aca-shared/store/src/effects/router.effects.spec.ts +++ b/projects/aca-shared/store/src/effects/router.effects.spec.ts @@ -89,7 +89,37 @@ describe('NodeEffects', () => { elements: [ { id: 'mock-id-1', - name: 'mock-name-1', + name: 'Company Home', + nodeType: 'mock-node-type' + }, + { + id: 'mock-id-2', + name: 'User Homes', + nodeType: 'mock-node-type' + }, + { + id: 'mock-id-3', + name: 'mock-name-3', + nodeType: 'mock-node-type' + } + ] + } + } as Node; + spyOn(router, 'navigate'); + store.dispatch(new NavigateToFolder({ entry: node })); + tick(10); + expect(router.navigate).toHaveBeenCalledWith(['/personal-files', 'mock-id']); + })); + + it('should navigate to folder inside repository when path elements are not personal files nor libraries', fakeAsync(() => { + const node = { + id: 'mock-id', + path: { + name: 'mock-path-name', + elements: [ + { + id: 'mock-id-1', + name: 'Company Home', nodeType: 'mock-node-type' }, { @@ -108,7 +138,7 @@ describe('NodeEffects', () => { spyOn(router, 'navigate'); store.dispatch(new NavigateToFolder({ entry: node })); tick(10); - expect(router.navigate).toHaveBeenCalledWith(['/personal-files', 'mock-id']); + expect(router.navigate).toHaveBeenCalledWith(['/repository', 'mock-id']); })); it('should navigate to folder nested libraries when path elements are found and are inside libraries', fakeAsync(() => { @@ -197,7 +227,37 @@ describe('NodeEffects', () => { elements: [ { id: 'mock-id-1', - name: 'mock-name-1', + name: 'Company Home', + nodeType: 'mock-node-type' + }, + { + id: 'mock-id-2', + name: 'User Homes', + nodeType: 'mock-node-type' + }, + { + id: 'mock-id-3', + name: 'mock-name-3', + nodeType: 'mock-node-type' + } + ] + } + } as Node; + spyOn(router, 'navigate'); + store.dispatch(new NavigateToParentFolder({ entry: node })); + tick(10); + expect(router.navigate).toHaveBeenCalledWith(['/personal-files', 'mock-id-3']); + })); + + it('should navigate to parent folder inside repository when path elements are not personal files nor libraries', fakeAsync(() => { + const node = { + id: 'mock-id', + path: { + name: 'mock-path-name', + elements: [ + { + id: 'mock-id-1', + name: 'Company Home', nodeType: 'mock-node-type' }, { @@ -216,7 +276,7 @@ describe('NodeEffects', () => { spyOn(router, 'navigate'); store.dispatch(new NavigateToParentFolder({ entry: node })); tick(10); - expect(router.navigate).toHaveBeenCalledWith(['/personal-files', 'mock-id-3']); + expect(router.navigate).toHaveBeenCalledWith(['/repository', 'mock-id-3']); })); it('should navigate to folder nested libraries when path elements are found and are inside libraries', fakeAsync(() => { diff --git a/projects/aca-shared/store/src/effects/router.effects.ts b/projects/aca-shared/store/src/effects/router.effects.ts index 8d8c135e7..05d867477 100644 --- a/projects/aca-shared/store/src/effects/router.effects.ts +++ b/projects/aca-shared/store/src/effects/router.effects.ts @@ -25,12 +25,13 @@ import { inject, Injectable } from '@angular/core'; import { Router } from '@angular/router'; import { Actions, createEffect, ofType } from '@ngrx/effects'; -import { Node, PathInfo } from '@alfresco/js-api'; +import { Node } from '@alfresco/js-api'; import { map } from 'rxjs/operators'; import { Location } from '@angular/common'; import { NavigateRouteAction, NavigateToFolder, NavigateToParentFolder, NavigateToPreviousPage, NavigateUrlAction } from '../actions/router.actions'; import { RouterActionTypes } from '../actions/router-action-types'; import { NotificationService } from '@alfresco/adf-core'; +import { getNodeContentSource } from '../utils/node-path.utils'; @Injectable() export class RouterEffects { @@ -103,10 +104,10 @@ export class RouterEffects { const { path, id } = node; if (path?.name && path?.elements) { - const isLibraryPath = this.isLibraryContent(path); + const area = `/${getNodeContentSource(path)}`; + const isLibraryPath = area === '/libraries'; const parent = path.elements[path.elements.length - 1]; - const area = isLibraryPath ? '/libraries' : '/personal-files'; if (!isLibraryPath) { link = [area, id]; @@ -128,10 +129,10 @@ export class RouterEffects { const { path } = node; if (path?.name && path?.elements) { - const isLibraryPath = this.isLibraryContent(path); + const area = `/${getNodeContentSource(path)}`; + const isLibraryPath = area === '/libraries'; const parent = path.elements[path.elements.length - 1]; - const area = isLibraryPath ? '/libraries' : '/personal-files'; if (!isLibraryPath) { link = [area, parent.id]; @@ -147,8 +148,4 @@ export class RouterEffects { this.notificationService.showError('APP.MESSAGES.ERRORS.CANNOT_NAVIGATE_LOCATION'); } } - - private isLibraryContent(path: PathInfo): boolean { - return path && path.elements.length >= 2 && path.elements[1].name === 'Sites'; - } } diff --git a/projects/aca-shared/store/src/public-api.ts b/projects/aca-shared/store/src/public-api.ts index 00b994953..449812f70 100644 --- a/projects/aca-shared/store/src/public-api.ts +++ b/projects/aca-shared/store/src/public-api.ts @@ -50,3 +50,5 @@ export * from './models/modal-configuration'; export * from './selectors/app.selectors'; export * from './states/app.state'; + +export * from './utils/node-path.utils'; diff --git a/projects/aca-shared/store/src/utils/node-path.utils.spec.ts b/projects/aca-shared/store/src/utils/node-path.utils.spec.ts new file mode 100644 index 000000000..a8e022a00 --- /dev/null +++ b/projects/aca-shared/store/src/utils/node-path.utils.spec.ts @@ -0,0 +1,62 @@ +/*! + * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Alfresco Example Content Application + * + * This file is part of the Alfresco Example Content Application. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * The Alfresco Example Content Application is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Alfresco Example Content Application is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * from Hyland Software. If not, see . + */ + +import { PathInfo } from '@alfresco/js-api'; +import { getNodeContentSource } from './node-path.utils'; + +describe('getNodeContentSource', () => { + it('should return [personal-files] when no path information is available', () => { + expect(getNodeContentSource(undefined)).toBe('personal-files'); + expect(getNodeContentSource({} as PathInfo)).toBe('personal-files'); + expect(getNodeContentSource({ elements: [] } as PathInfo)).toBe('personal-files'); + }); + + it('should return [personal-files] when User Homes is the second path element', () => { + const path = { + name: '/Company Home/User Homes/user1', + elements: [{ name: 'Company Home' }, { name: 'User Homes' }, { name: 'user1' }] + } as PathInfo; + expect(getNodeContentSource(path)).toBe('personal-files'); + }); + + it('should return [personal-files] when User Homes is present in the path name', () => { + const path = { name: '/Company Home/User Homes/user1/folder', elements: [{ name: 'Company Home' }, { name: 'User Homes' }] } as PathInfo; + expect(getNodeContentSource(path)).toBe('personal-files'); + }); + + it('should return [libraries] for a site path', () => { + const path = { name: '/Company Home/Sites/my-site', elements: [{ name: 'Company Home' }, { name: 'Sites' }, { name: 'my-site' }] } as PathInfo; + expect(getNodeContentSource(path)).toBe('libraries'); + }); + + it('should return [repository] when only the repository root is present', () => { + const path = { name: '/Company Home', elements: [{ name: 'Company Home' }] } as PathInfo; + expect(getNodeContentSource(path)).toBe('repository'); + }); + + it('should return [repository] for any other path nested under the repository root', () => { + const path = { name: '/Company Home/Some Folder', elements: [{ name: 'Company Home' }, { name: 'Some Folder' }] } as PathInfo; + expect(getNodeContentSource(path)).toBe('repository'); + }); +}); diff --git a/projects/aca-shared/store/src/utils/node-path.utils.ts b/projects/aca-shared/store/src/utils/node-path.utils.ts new file mode 100644 index 000000000..bd4a9a057 --- /dev/null +++ b/projects/aca-shared/store/src/utils/node-path.utils.ts @@ -0,0 +1,59 @@ +/*! + * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Alfresco Example Content Application + * + * This file is part of the Alfresco Example Content Application. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * The Alfresco Example Content Application is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Alfresco Example Content Application is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * from Hyland Software. If not, see . + */ + +import { PathInfo } from '@alfresco/js-api'; + +/** + * The browsing area a node belongs to, derived from its primary path. The values match the + * application routes (`/personal-files`, `/libraries`, `/repository`). + */ +export type NodeContentSource = 'personal-files' | 'libraries' | 'repository'; + +const PERSONAL_FILES_FOLDER = 'User Homes'; +const LIBRARIES_FOLDER = 'Sites'; + +/** + * Resolves the browsing area of a node from its path. When no path information is available the default of + * `personal-files` is kept. + * + * @param path path of the node + * @returns The content source the node should be navigated to + */ +export function getNodeContentSource(path: PathInfo): NodeContentSource { + const elements = path?.elements ?? []; + + if (elements.length === 0) { + return 'personal-files'; + } + + if (elements[1]?.name === LIBRARIES_FOLDER) { + return 'libraries'; + } + + if (path?.name?.includes(PERSONAL_FILES_FOLDER) || elements[1]?.name === PERSONAL_FILES_FOLDER) { + return 'personal-files'; + } + + return 'repository'; +}