diff --git a/src/app/admin/admin-sidebar/admin-sidebar.component.spec.ts b/src/app/admin/admin-sidebar/admin-sidebar.component.spec.ts index 65026c1504..3aca092b52 100644 --- a/src/app/admin/admin-sidebar/admin-sidebar.component.spec.ts +++ b/src/app/admin/admin-sidebar/admin-sidebar.component.spec.ts @@ -182,150 +182,4 @@ describe('AdminSidebarComponent', () => { expect(menuService.collapseMenuPreview).toHaveBeenCalled(); })); }); - - describe('menu', () => { - beforeEach(() => { - spyOn(menuService, 'addSection'); - }); - - describe('for regular user', () => { - beforeEach(() => { - authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake(() => { - return observableOf(false); - }); - }); - - beforeEach(() => { - comp.createMenu(); - }); - - it('should not show site admin section', () => { - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'admin_search', visible: false, - })); - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'registries', visible: false, - })); - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - parentID: 'registries', visible: false, - })); - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'curation_tasks', visible: false, - })); - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'workflow', visible: false, - })); - }); - - it('should not show edit_community', () => { - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'edit_community', visible: false, - })); - - }); - - it('should not show edit_collection', () => { - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'edit_collection', visible: false, - })); - }); - - it('should not show access control section', () => { - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'access_control', visible: false, - })); - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - parentID: 'access_control', visible: false, - })); - - }); - }); - - describe('for site admin', () => { - beforeEach(() => { - authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake((featureID: FeatureID) => { - return observableOf(featureID === FeatureID.AdministratorOf); - }); - }); - - beforeEach(() => { - comp.createMenu(); - }); - - it('should contain site admin section', () => { - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'admin_search', visible: true, - })); - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'registries', visible: true, - })); - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - parentID: 'registries', visible: true, - })); - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'curation_tasks', visible: true, - })); - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'workflow', visible: true, - })); - }); - }); - - describe('for community admin', () => { - beforeEach(() => { - authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake((featureID: FeatureID) => { - return observableOf(featureID === FeatureID.IsCommunityAdmin); - }); - }); - - beforeEach(() => { - comp.createMenu(); - }); - - it('should show edit_community', () => { - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'edit_community', visible: true, - })); - }); - }); - - describe('for collection admin', () => { - beforeEach(() => { - authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake((featureID: FeatureID) => { - return observableOf(featureID === FeatureID.IsCollectionAdmin); - }); - }); - - beforeEach(() => { - comp.createMenu(); - }); - - it('should show edit_collection', () => { - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'edit_collection', visible: true, - })); - }); - }); - - describe('for group admin', () => { - beforeEach(() => { - authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake((featureID: FeatureID) => { - return observableOf(featureID === FeatureID.CanManageGroups); - }); - }); - - beforeEach(() => { - comp.createMenu(); - }); - - it('should show access control section', () => { - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'access_control', visible: true, - })); - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - parentID: 'access_control', visible: true, - })); - }); - }); - }); }); diff --git a/src/app/admin/admin-sidebar/admin-sidebar.component.ts b/src/app/admin/admin-sidebar/admin-sidebar.component.ts index c81b2e6e93..d07196502f 100644 --- a/src/app/admin/admin-sidebar/admin-sidebar.component.ts +++ b/src/app/admin/admin-sidebar/admin-sidebar.component.ts @@ -67,9 +67,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { protected injector: Injector, private variableService: CSSVariableService, private authService: AuthService, - private modalService: NgbModal, public authorizationService: AuthorizationDataService, - private scriptDataService: ScriptDataService, public route: ActivatedRoute ) { super(menuService, injector, authorizationService, route); @@ -80,7 +78,6 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { * Set and calculate all initial values of the instance variables */ ngOnInit(): void { - this.createMenu(); super.ngOnInit(); this.sidebarWidth = this.variableService.getVariable('sidebarItemsWidth'); this.authService.isAuthenticated() @@ -115,501 +112,6 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { }); } - /** - * Initialize all menu sections and items for this menu - */ - createMenu() { - this.createMainMenuSections(); - this.createSiteAdministratorMenuSections(); - this.createExportMenuSections(); - this.createImportMenuSections(); - this.createAccessControlMenuSections(); - } - - /** - * Initialize the main menu sections. - * edit_community / edit_collection is only included if the current user is a Community or Collection admin - */ - createMainMenuSections() { - combineLatest([ - this.authorizationService.isAuthorized(FeatureID.IsCollectionAdmin), - this.authorizationService.isAuthorized(FeatureID.IsCommunityAdmin), - this.authorizationService.isAuthorized(FeatureID.AdministratorOf) - ]).subscribe(([isCollectionAdmin, isCommunityAdmin, isSiteAdmin]) => { - const menuList = [ - /* News */ - { - id: 'new', - active: false, - visible: true, - model: { - type: MenuItemType.TEXT, - text: 'menu.section.new' - } as TextMenuItemModel, - icon: 'plus', - index: 0 - }, - { - id: 'new_community', - parentID: 'new', - active: false, - visible: isCommunityAdmin, - model: { - type: MenuItemType.ONCLICK, - text: 'menu.section.new_community', - function: () => { - this.modalService.open(CreateCommunityParentSelectorComponent); - } - } as OnClickMenuItemModel, - }, - { - id: 'new_collection', - parentID: 'new', - active: false, - visible: isCommunityAdmin, - model: { - type: MenuItemType.ONCLICK, - text: 'menu.section.new_collection', - function: () => { - this.modalService.open(CreateCollectionParentSelectorComponent); - } - } as OnClickMenuItemModel, - }, - { - id: 'new_item', - parentID: 'new', - active: false, - visible: true, - model: { - type: MenuItemType.ONCLICK, - text: 'menu.section.new_item', - function: () => { - this.modalService.open(CreateItemParentSelectorComponent); - } - } as OnClickMenuItemModel, - }, - { - id: 'new_process', - parentID: 'new', - active: false, - visible: isCollectionAdmin, - model: { - type: MenuItemType.LINK, - text: 'menu.section.new_process', - link: '/processes/new' - } as LinkMenuItemModel, - }, - // TODO: enable this menu item once the feature has been implemented - // { - // id: 'new_item_version', - // parentID: 'new', - // active: false, - // visible: true, - // model: { - // type: MenuItemType.LINK, - // text: 'menu.section.new_item_version', - // link: '' - // } as LinkMenuItemModel, - // }, - - /* Edit */ - { - id: 'edit', - active: false, - visible: true, - model: { - type: MenuItemType.TEXT, - text: 'menu.section.edit' - } as TextMenuItemModel, - icon: 'pencil-alt', - index: 1 - }, - { - id: 'edit_community', - parentID: 'edit', - active: false, - visible: isCommunityAdmin, - model: { - type: MenuItemType.ONCLICK, - text: 'menu.section.edit_community', - function: () => { - this.modalService.open(EditCommunitySelectorComponent); - } - } as OnClickMenuItemModel, - }, - { - id: 'edit_collection', - parentID: 'edit', - active: false, - visible: isCollectionAdmin, - model: { - type: MenuItemType.ONCLICK, - text: 'menu.section.edit_collection', - function: () => { - this.modalService.open(EditCollectionSelectorComponent); - } - } as OnClickMenuItemModel, - }, - { - id: 'edit_item', - parentID: 'edit', - active: false, - visible: true, - model: { - type: MenuItemType.ONCLICK, - text: 'menu.section.edit_item', - function: () => { - this.modalService.open(EditItemSelectorComponent); - } - } as OnClickMenuItemModel, - }, - - /* Statistics */ - // TODO: enable this menu item once the feature has been implemented - // { - // id: 'statistics_task', - // active: false, - // visible: true, - // model: { - // type: MenuItemType.LINK, - // text: 'menu.section.statistics_task', - // link: '' - // } as LinkMenuItemModel, - // icon: 'chart-bar', - // index: 8 - // }, - - /* Control Panel */ - // TODO: enable this menu item once the feature has been implemented - // { - // id: 'control_panel', - // active: false, - // visible: isSiteAdmin, - // model: { - // type: MenuItemType.LINK, - // text: 'menu.section.control_panel', - // link: '' - // } as LinkMenuItemModel, - // icon: 'cogs', - // index: 9 - // }, - - /* Processes */ - { - id: 'processes', - active: false, - visible: isSiteAdmin, - model: { - type: MenuItemType.LINK, - text: 'menu.section.processes', - link: '/processes' - } as LinkMenuItemModel, - icon: 'terminal', - index: 10 - }, - ]; - menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, Object.assign(menuSection, { - shouldPersistOnRouteChange: true - }))); - }); - } - - /** - * Create menu sections dependent on whether or not the current user is a site administrator and on whether or not - * the export scripts exist and the current user is allowed to execute them - */ - createExportMenuSections() { - const menuList = [ - /* Export */ - { - id: 'export', - active: false, - visible: true, - model: { - type: MenuItemType.TEXT, - text: 'menu.section.export' - } as TextMenuItemModel, - icon: 'file-export', - index: 3, - shouldPersistOnRouteChange: true - }, - // TODO: enable this menu item once the feature has been implemented - // { - // id: 'export_community', - // parentID: 'export', - // active: false, - // visible: true, - // model: { - // type: MenuItemType.LINK, - // text: 'menu.section.export_community', - // link: '' - // } as LinkMenuItemModel, - // shouldPersistOnRouteChange: true - // }, - // TODO: enable this menu item once the feature has been implemented - // { - // id: 'export_collection', - // parentID: 'export', - // active: false, - // visible: true, - // model: { - // type: MenuItemType.LINK, - // text: 'menu.section.export_collection', - // link: '' - // } as LinkMenuItemModel, - // shouldPersistOnRouteChange: true - // }, - // TODO: enable this menu item once the feature has been implemented - // { - // id: 'export_item', - // parentID: 'export', - // active: false, - // visible: true, - // model: { - // type: MenuItemType.LINK, - // text: 'menu.section.export_item', - // link: '' - // } as LinkMenuItemModel, - // shouldPersistOnRouteChange: true - // }, - ]; - menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, menuSection)); - - observableCombineLatest( - this.authorizationService.isAuthorized(FeatureID.AdministratorOf), - // this.scriptDataService.scriptWithNameExistsAndCanExecute(METADATA_EXPORT_SCRIPT_NAME) - ).pipe( - // TODO uncomment when #635 (https://github.com/DSpace/dspace-angular/issues/635) is fixed; otherwise even in production mode, the metadata export button is only available after a refresh (and not in dev mode) - // filter(([authorized, metadataExportScriptExists]: boolean[]) => authorized && metadataExportScriptExists), - take(1) - ).subscribe(() => { - this.menuService.addSection(this.menuID, { - id: 'export_metadata', - parentID: 'export', - active: true, - visible: true, - model: { - type: MenuItemType.ONCLICK, - text: 'menu.section.export_metadata', - function: () => { - this.modalService.open(ExportMetadataSelectorComponent); - } - } as OnClickMenuItemModel, - shouldPersistOnRouteChange: true - }); - }); - } - - /** - * Create menu sections dependent on whether or not the current user is a site administrator and on whether or not - * the import scripts exist and the current user is allowed to execute them - */ - createImportMenuSections() { - const menuList = [ - /* Import */ - { - id: 'import', - active: false, - visible: true, - model: { - type: MenuItemType.TEXT, - text: 'menu.section.import' - } as TextMenuItemModel, - icon: 'file-import', - index: 2 - }, - // TODO: enable this menu item once the feature has been implemented - // { - // id: 'import_batch', - // parentID: 'import', - // active: false, - // visible: true, - // model: { - // type: MenuItemType.LINK, - // text: 'menu.section.import_batch', - // link: '' - // } as LinkMenuItemModel, - // } - ]; - menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, Object.assign(menuSection, { - shouldPersistOnRouteChange: true - }))); - - observableCombineLatest( - this.authorizationService.isAuthorized(FeatureID.AdministratorOf), - // this.scriptDataService.scriptWithNameExistsAndCanExecute(METADATA_IMPORT_SCRIPT_NAME) - ).pipe( - // TODO uncomment when #635 (https://github.com/DSpace/dspace-angular/issues/635) is fixed - // filter(([authorized, metadataImportScriptExists]: boolean[]) => authorized && metadataImportScriptExists), - take(1) - ).subscribe(() => { - this.menuService.addSection(this.menuID, { - id: 'import_metadata', - parentID: 'import', - active: true, - visible: true, - model: { - type: MenuItemType.LINK, - text: 'menu.section.import_metadata', - link: '/admin/metadata-import' - } as LinkMenuItemModel, - shouldPersistOnRouteChange: true - }); - }); - } - - /** - * Create menu sections dependent on whether or not the current user is a site administrator - */ - createSiteAdministratorMenuSections() { - this.authorizationService.isAuthorized(FeatureID.AdministratorOf).subscribe((authorized) => { - const menuList = [ - /* Admin Search */ - { - id: 'admin_search', - active: false, - visible: authorized, - model: { - type: MenuItemType.LINK, - text: 'menu.section.admin_search', - link: '/admin/search' - } as LinkMenuItemModel, - icon: 'search', - index: 5 - }, - /* Registries */ - { - id: 'registries', - active: false, - visible: authorized, - model: { - type: MenuItemType.TEXT, - text: 'menu.section.registries' - } as TextMenuItemModel, - icon: 'list', - index: 6 - }, - { - id: 'registries_metadata', - parentID: 'registries', - active: false, - visible: authorized, - model: { - type: MenuItemType.LINK, - text: 'menu.section.registries_metadata', - link: 'admin/registries/metadata' - } as LinkMenuItemModel, - }, - { - id: 'registries_format', - parentID: 'registries', - active: false, - visible: authorized, - model: { - type: MenuItemType.LINK, - text: 'menu.section.registries_format', - link: 'admin/registries/bitstream-formats' - } as LinkMenuItemModel, - }, - - /* Curation tasks */ - { - id: 'curation_tasks', - active: false, - visible: authorized, - model: { - type: MenuItemType.LINK, - text: 'menu.section.curation_task', - link: 'admin/curation-tasks' - } as LinkMenuItemModel, - icon: 'filter', - index: 7 - }, - - /* Workflow */ - { - id: 'workflow', - active: false, - visible: authorized, - model: { - type: MenuItemType.LINK, - text: 'menu.section.workflow', - link: '/admin/workflow' - } as LinkMenuItemModel, - icon: 'user-check', - index: 11 - }, - ]; - - menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, Object.assign(menuSection, { - shouldPersistOnRouteChange: true - }))); - }); - } - - /** - * Create menu sections dependent on whether or not the current user can manage access control groups - */ - createAccessControlMenuSections() { - observableCombineLatest( - this.authorizationService.isAuthorized(FeatureID.AdministratorOf), - this.authorizationService.isAuthorized(FeatureID.CanManageGroups) - ).subscribe(([isSiteAdmin, canManageGroups]) => { - const menuList = [ - /* Access Control */ - { - id: 'access_control_people', - parentID: 'access_control', - active: false, - visible: isSiteAdmin, - model: { - type: MenuItemType.LINK, - text: 'menu.section.access_control_people', - link: '/access-control/epeople' - } as LinkMenuItemModel, - }, - { - id: 'access_control_groups', - parentID: 'access_control', - active: false, - visible: canManageGroups, - model: { - type: MenuItemType.LINK, - text: 'menu.section.access_control_groups', - link: '/access-control/groups' - } as LinkMenuItemModel, - }, - // TODO: enable this menu item once the feature has been implemented - // { - // id: 'access_control_authorizations', - // parentID: 'access_control', - // active: false, - // visible: authorized, - // model: { - // type: MenuItemType.LINK, - // text: 'menu.section.access_control_authorizations', - // link: '' - // } as LinkMenuItemModel, - // }, - { - id: 'access_control', - active: false, - visible: canManageGroups || isSiteAdmin, - model: { - type: MenuItemType.TEXT, - text: 'menu.section.access_control' - } as TextMenuItemModel, - icon: 'key', - index: 4 - }, - ]; - - menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, Object.assign(menuSection, { - shouldPersistOnRouteChange: true, - }))); - }); - } - @HostListener('focusin') public handleFocusIn() { this.inFocus$.next(true); diff --git a/src/app/menu.resolver.spec.ts b/src/app/menu.resolver.spec.ts index 76adc49bc9..7fa156fc4e 100644 --- a/src/app/menu.resolver.spec.ts +++ b/src/app/menu.resolver.spec.ts @@ -1,16 +1,305 @@ -import { TestBed } from '@angular/core/testing'; +import { TestBed, waitForAsync } from '@angular/core/testing'; import { MenuResolver } from './menu.resolver'; +import { of as observableOf } from 'rxjs'; +import { FeatureID } from './core/data/feature-authorization/feature-id'; +import { TranslateModule } from '@ngx-translate/core'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { RouterTestingModule } from '@angular/router/testing'; +import { AdminSidebarComponent } from './admin/admin-sidebar/admin-sidebar.component'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { MenuService } from './shared/menu/menu.service'; +import { AuthorizationDataService } from './core/data/feature-authorization/authorization-data.service'; +import { ScriptDataService } from './core/data/processes/script-data.service'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { MenuServiceStub } from './shared/testing/menu-service.stub'; +import { MenuID } from './shared/menu/initial-menus-state'; +import { BrowseService } from './core/browse/browse.service'; +import { cold } from 'jasmine-marbles'; +import createSpy = jasmine.createSpy; +import { createSuccessfulRemoteDataObject$ } from './shared/remote-data.utils'; +import { createPaginatedList } from './shared/testing/utils.test'; + +const BOOLEAN = { t: true, f: false }; +const MENU_STATE = { + id: 'some menu' +}; +const BROWSE_DEFINITIONS = [ + { id: 'definition1' }, + { id: 'definition2' }, + { id: 'definition3' }, +]; describe('MenuResolver', () => { let resolver: MenuResolver; - beforeEach(() => { - TestBed.configureTestingModule({}); + let menuService; + let browseService; + let authorizationService; + let scriptService; + + beforeEach(waitForAsync(() => { + menuService = new MenuServiceStub(); + spyOn(menuService, 'getMenu').and.returnValue(observableOf(MENU_STATE)); + + browseService = jasmine.createSpyObj('browseService', { + getBrowseDefinitions: createSuccessfulRemoteDataObject$(createPaginatedList(BROWSE_DEFINITIONS)) + }); + authorizationService = jasmine.createSpyObj('authorizationService', { + isAuthorized: observableOf(true) + }); + scriptService = jasmine.createSpyObj('scriptService', { + scriptWithNameExistsAndCanExecute: observableOf(true) + }); + + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), NoopAnimationsModule, RouterTestingModule], + declarations: [AdminSidebarComponent], + providers: [ + { provide: MenuService, useValue: menuService }, + { provide: BrowseService, useValue: browseService }, + { provide: AuthorizationDataService, useValue: authorizationService }, + { provide: ScriptDataService, useValue: scriptService }, + { + provide: NgbModal, useValue: { + open: () => {/*comment*/ + } + } + } + ], + schemas: [NO_ERRORS_SCHEMA] + }); resolver = TestBed.inject(MenuResolver); - }); + + spyOn(menuService, 'addSection'); + })); it('should be created', () => { expect(resolver).toBeTruthy(); }); + + describe('resolve', () => { + it('should create all menus', (done) => { + spyOn(resolver, 'createPublicMenu$').and.returnValue(observableOf(true)); + spyOn(resolver, 'createAdminMenu$').and.returnValue(observableOf(true)); + + resolver.resolve(null, null).subscribe(resolved => { + expect(resolved).toBeTrue(); + expect(resolver.createPublicMenu$).toHaveBeenCalled(); + expect(resolver.createAdminMenu$).toHaveBeenCalled(); + done(); + }); + }); + + it('should return an Observable that emits true as soon as all menus are created', () => { + spyOn(resolver, 'createPublicMenu$').and.returnValue(cold('--(t|)', BOOLEAN)); + spyOn(resolver, 'createAdminMenu$').and.returnValue(cold('----(t|)', BOOLEAN)); + + expect(resolver.resolve(null, null)).toBeObservable(cold('----(t|)', BOOLEAN)); + }); + }); + + describe('createPublicMenu$', () => { + it('should retrieve the menu by ID return an Observable that emits true as soon as it is created', () => { + (menuService as any).getMenu.and.returnValue(cold('--u--m--', { + u: undefined, + m: MENU_STATE, + })); + + expect(resolver.createPublicMenu$()).toBeObservable(cold('-----(t|)', BOOLEAN)); + expect(menuService.getMenu).toHaveBeenCalledOnceWith(MenuID.PUBLIC); + }); + + describe('contents', () => { + beforeEach((done) => { + resolver.createPublicMenu$().subscribe((_) => { + done(); + }); + }); + + it('should include community list link', () => { + expect(menuService.addSection).toHaveBeenCalledWith(MenuID.PUBLIC, jasmine.objectContaining({ + id: 'browse_global_communities_and_collections', visible: true, + })); + }); + + it('should include browse dropdown', () => { + expect(menuService.addSection).toHaveBeenCalledWith(MenuID.PUBLIC, jasmine.objectContaining({ + id: 'browse_global_by_definition1', parentID: 'browse_global', visible: true, + })); + expect(menuService.addSection).toHaveBeenCalledWith(MenuID.PUBLIC, jasmine.objectContaining({ + id: 'browse_global_by_definition2', parentID: 'browse_global', visible: true, + })); + expect(menuService.addSection).toHaveBeenCalledWith(MenuID.PUBLIC, jasmine.objectContaining({ + id: 'browse_global_by_definition3', parentID: 'browse_global', visible: true, + })); + + expect(menuService.addSection).toHaveBeenCalledWith(MenuID.PUBLIC, jasmine.objectContaining({ + id: 'browse_global', visible: true, + })); + }); + }); + }); + + describe('createAdminMenu$', () => { + it('should retrieve the menu by ID return an Observable that emits true as soon as it is created', () => { + (menuService as any).getMenu.and.returnValue(cold('--u--m', { + u: undefined, + m: MENU_STATE, + })); + + expect(resolver.createAdminMenu$()).toBeObservable(cold('-----(t|)', BOOLEAN)); + expect(menuService.getMenu).toHaveBeenCalledOnceWith(MenuID.ADMIN); + }); + + describe('for regular user', () => { + beforeEach(() => { + authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake(() => { + return observableOf(false); + }); + }); + + beforeEach((done) => { + resolver.createAdminMenu$().subscribe((_) => { + done(); + }); + }); + + it('should not show site admin section', () => { + expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ + id: 'admin_search', visible: false, + })); + expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ + id: 'registries', visible: false, + })); + expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ + parentID: 'registries', visible: false, + })); + expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ + id: 'curation_tasks', visible: false, + })); + expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ + id: 'workflow', visible: false, + })); + }); + + it('should not show edit_community', () => { + expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ + id: 'edit_community', visible: false, + })); + + }); + + it('should not show edit_collection', () => { + expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ + id: 'edit_collection', visible: false, + })); + }); + + it('should not show access control section', () => { + expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ + id: 'access_control', visible: false, + })); + expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ + parentID: 'access_control', visible: false, + })); + + }); + }); + + describe('for site admin', () => { + beforeEach(() => { + authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake((featureID: FeatureID) => { + return observableOf(featureID === FeatureID.AdministratorOf); + }); + }); + + beforeEach((done) => { + resolver.createAdminMenu$().subscribe((_) => { + done(); + }); + }); + + it('should contain site admin section', () => { + expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ + id: 'admin_search', visible: true, + })); + expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ + id: 'registries', visible: true, + })); + expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ + parentID: 'registries', visible: true, + })); + expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ + id: 'curation_tasks', visible: true, + })); + expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ + id: 'workflow', visible: true, + })); + }); + }); + + describe('for community admin', () => { + beforeEach(() => { + authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake((featureID: FeatureID) => { + return observableOf(featureID === FeatureID.IsCommunityAdmin); + }); + }); + + beforeEach((done) => { + resolver.createAdminMenu$().subscribe((_) => { + done(); + }); + }); + + it('should show edit_community', () => { + expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ + id: 'edit_community', visible: true, + })); + }); + }); + + describe('for collection admin', () => { + beforeEach(() => { + authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake((featureID: FeatureID) => { + return observableOf(featureID === FeatureID.IsCollectionAdmin); + }); + }); + + beforeEach((done) => { + resolver.createAdminMenu$().subscribe((_) => { + done(); + }); + }); + + it('should show edit_collection', () => { + expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ + id: 'edit_collection', visible: true, + })); + }); + }); + + describe('for group admin', () => { + beforeEach(() => { + authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake((featureID: FeatureID) => { + return observableOf(featureID === FeatureID.CanManageGroups); + }); + }); + + beforeEach((done) => { + resolver.createAdminMenu$().subscribe((_) => { + done(); + }); + }); + + it('should show access control section', () => { + expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ + id: 'access_control', visible: true, + })); + expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ + parentID: 'access_control', visible: true, + })); + }); + }); + }); }); diff --git a/src/app/menu.resolver.ts b/src/app/menu.resolver.ts index 12af6236fe..e6e30dcbcc 100644 --- a/src/app/menu.resolver.ts +++ b/src/app/menu.resolver.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; -import { Resolve, RouterStateSnapshot, ActivatedRouteSnapshot } from '@angular/router'; -import { Observable } from 'rxjs'; -import { MenuItemType, MenuID } from './shared/menu/initial-menus-state'; +import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; +import { combineLatest as observableCombineLatest, combineLatest, Observable } from 'rxjs'; +import { MenuID, MenuItemType } from './shared/menu/initial-menus-state'; import { LinkMenuItemModel } from './shared/menu/menu-item/models/link.model'; import { getFirstCompletedRemoteData } from './core/shared/operators'; import { PaginatedList } from './core/data/paginated-list.model'; @@ -11,28 +11,63 @@ import { TextMenuItemModel } from './shared/menu/menu-item/models/text.model'; import { BrowseService } from './core/browse/browse.service'; import { MenuService } from './shared/menu/menu.service'; import { MenuState } from './shared/menu/menu.reducer'; -import { find, map } from 'rxjs/operators'; +import { find, map, take } from 'rxjs/operators'; import { hasValue } from './shared/empty.util'; +import { FeatureID } from './core/data/feature-authorization/feature-id'; +import { CreateCommunityParentSelectorComponent } from './shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component'; +import { OnClickMenuItemModel } from './shared/menu/menu-item/models/onclick.model'; +import { CreateCollectionParentSelectorComponent } from './shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component'; +import { CreateItemParentSelectorComponent } from './shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component'; +import { EditCommunitySelectorComponent } from './shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component'; +import { EditCollectionSelectorComponent } from './shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component'; +import { EditItemSelectorComponent } from './shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component'; +import { ExportMetadataSelectorComponent } from './shared/dso-selector/modal-wrappers/export-metadata-selector/export-metadata-selector.component'; +import { AuthorizationDataService } from './core/data/feature-authorization/authorization-data.service'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +/** + * Creates all of the app's menus + */ @Injectable({ providedIn: 'root' }) export class MenuResolver implements Resolve { constructor( protected menuService: MenuService, - public browseService: BrowseService, + protected browseService: BrowseService, + protected authorizationService: AuthorizationDataService, + protected modalService: NgbModal, ) { } + /** + * Initialize all menus + */ resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - this.createPublicMenu(); - return this.menuService.getMenu(MenuID.PUBLIC).pipe( - find((menu: MenuState) => hasValue(menu)), - map(() => true) + return combineLatest([ + this.createPublicMenu$(), + this.createAdminMenu$(), + ]).pipe( + map((menusDone: boolean[]) => menusDone.every(Boolean)), ); } - createPublicMenu() { + /** + * Wait for a specific menu to appear + * @param id the ID of the menu to wait for + * @return an Observable that emits true as soon as the menu is created + */ + protected waitForMenu$(id: MenuID): Observable { + return this.menuService.getMenu(id).pipe( + find((menu: MenuState) => hasValue(menu)), + map(() => true), + ); + } + + /** + * Initialize all menu sections and items for {@link MenuID.PUBLIC} + */ + createPublicMenu$(): Observable { const menuList: any[] = [ /* Communities & Collections tree */ { @@ -84,6 +119,504 @@ export class MenuResolver implements Resolve { }))); }); + return this.waitForMenu$(MenuID.PUBLIC); + } + + /** + * Initialize all menu sections and items for {@link MenuID.ADMIN} + */ + createAdminMenu$() { + this.createMainMenuSections(); + this.createSiteAdministratorMenuSections(); + this.createExportMenuSections(); + this.createImportMenuSections(); + this.createAccessControlMenuSections(); + + return this.waitForMenu$(MenuID.ADMIN); + } + + /** + * Initialize the main menu sections. + * edit_community / edit_collection is only included if the current user is a Community or Collection admin + */ + createMainMenuSections() { + combineLatest([ + this.authorizationService.isAuthorized(FeatureID.IsCollectionAdmin), + this.authorizationService.isAuthorized(FeatureID.IsCommunityAdmin), + this.authorizationService.isAuthorized(FeatureID.AdministratorOf) + ]).subscribe(([isCollectionAdmin, isCommunityAdmin, isSiteAdmin]) => { + const menuList = [ + /* News */ + { + id: 'new', + active: false, + visible: true, + model: { + type: MenuItemType.TEXT, + text: 'menu.section.new' + } as TextMenuItemModel, + icon: 'plus', + index: 0 + }, + { + id: 'new_community', + parentID: 'new', + active: false, + visible: isCommunityAdmin, + model: { + type: MenuItemType.ONCLICK, + text: 'menu.section.new_community', + function: () => { + this.modalService.open(CreateCommunityParentSelectorComponent); + } + } as OnClickMenuItemModel, + }, + { + id: 'new_collection', + parentID: 'new', + active: false, + visible: isCommunityAdmin, + model: { + type: MenuItemType.ONCLICK, + text: 'menu.section.new_collection', + function: () => { + this.modalService.open(CreateCollectionParentSelectorComponent); + } + } as OnClickMenuItemModel, + }, + { + id: 'new_item', + parentID: 'new', + active: false, + visible: true, + model: { + type: MenuItemType.ONCLICK, + text: 'menu.section.new_item', + function: () => { + this.modalService.open(CreateItemParentSelectorComponent); + } + } as OnClickMenuItemModel, + }, + { + id: 'new_process', + parentID: 'new', + active: false, + visible: isCollectionAdmin, + model: { + type: MenuItemType.LINK, + text: 'menu.section.new_process', + link: '/processes/new' + } as LinkMenuItemModel, + }, + // TODO: enable this menu item once the feature has been implemented + // { + // id: 'new_item_version', + // parentID: 'new', + // active: false, + // visible: true, + // model: { + // type: MenuItemType.LINK, + // text: 'menu.section.new_item_version', + // link: '' + // } as LinkMenuItemModel, + // }, + + /* Edit */ + { + id: 'edit', + active: false, + visible: true, + model: { + type: MenuItemType.TEXT, + text: 'menu.section.edit' + } as TextMenuItemModel, + icon: 'pencil-alt', + index: 1 + }, + { + id: 'edit_community', + parentID: 'edit', + active: false, + visible: isCommunityAdmin, + model: { + type: MenuItemType.ONCLICK, + text: 'menu.section.edit_community', + function: () => { + this.modalService.open(EditCommunitySelectorComponent); + } + } as OnClickMenuItemModel, + }, + { + id: 'edit_collection', + parentID: 'edit', + active: false, + visible: isCollectionAdmin, + model: { + type: MenuItemType.ONCLICK, + text: 'menu.section.edit_collection', + function: () => { + this.modalService.open(EditCollectionSelectorComponent); + } + } as OnClickMenuItemModel, + }, + { + id: 'edit_item', + parentID: 'edit', + active: false, + visible: true, + model: { + type: MenuItemType.ONCLICK, + text: 'menu.section.edit_item', + function: () => { + this.modalService.open(EditItemSelectorComponent); + } + } as OnClickMenuItemModel, + }, + + /* Statistics */ + // TODO: enable this menu item once the feature has been implemented + // { + // id: 'statistics_task', + // active: false, + // visible: true, + // model: { + // type: MenuItemType.LINK, + // text: 'menu.section.statistics_task', + // link: '' + // } as LinkMenuItemModel, + // icon: 'chart-bar', + // index: 8 + // }, + + /* Control Panel */ + // TODO: enable this menu item once the feature has been implemented + // { + // id: 'control_panel', + // active: false, + // visible: isSiteAdmin, + // model: { + // type: MenuItemType.LINK, + // text: 'menu.section.control_panel', + // link: '' + // } as LinkMenuItemModel, + // icon: 'cogs', + // index: 9 + // }, + + /* Processes */ + { + id: 'processes', + active: false, + visible: isSiteAdmin, + model: { + type: MenuItemType.LINK, + text: 'menu.section.processes', + link: '/processes' + } as LinkMenuItemModel, + icon: 'terminal', + index: 10 + }, + ]; + menuList.forEach((menuSection) => this.menuService.addSection(MenuID.ADMIN, Object.assign(menuSection, { + shouldPersistOnRouteChange: true + }))); + }); + } + + /** + * Create menu sections dependent on whether or not the current user is a site administrator and on whether or not + * the export scripts exist and the current user is allowed to execute them + */ + createExportMenuSections() { + const menuList = [ + /* Export */ + { + id: 'export', + active: false, + visible: true, + model: { + type: MenuItemType.TEXT, + text: 'menu.section.export' + } as TextMenuItemModel, + icon: 'file-export', + index: 3, + shouldPersistOnRouteChange: true + }, + // TODO: enable this menu item once the feature has been implemented + // { + // id: 'export_community', + // parentID: 'export', + // active: false, + // visible: true, + // model: { + // type: MenuItemType.LINK, + // text: 'menu.section.export_community', + // link: '' + // } as LinkMenuItemModel, + // shouldPersistOnRouteChange: true + // }, + // TODO: enable this menu item once the feature has been implemented + // { + // id: 'export_collection', + // parentID: 'export', + // active: false, + // visible: true, + // model: { + // type: MenuItemType.LINK, + // text: 'menu.section.export_collection', + // link: '' + // } as LinkMenuItemModel, + // shouldPersistOnRouteChange: true + // }, + // TODO: enable this menu item once the feature has been implemented + // { + // id: 'export_item', + // parentID: 'export', + // active: false, + // visible: true, + // model: { + // type: MenuItemType.LINK, + // text: 'menu.section.export_item', + // link: '' + // } as LinkMenuItemModel, + // shouldPersistOnRouteChange: true + // }, + ]; + menuList.forEach((menuSection) => this.menuService.addSection(MenuID.ADMIN, menuSection)); + + observableCombineLatest( + this.authorizationService.isAuthorized(FeatureID.AdministratorOf), + // this.scriptDataService.scriptWithNameExistsAndCanExecute(METADATA_EXPORT_SCRIPT_NAME) + ).pipe( + // TODO uncomment when #635 (https://github.com/DSpace/dspace-angular/issues/635) is fixed; otherwise even in production mode, the metadata export button is only available after a refresh (and not in dev mode) + // filter(([authorized, metadataExportScriptExists]: boolean[]) => authorized && metadataExportScriptExists), + take(1) + ).subscribe(() => { + this.menuService.addSection(MenuID.ADMIN, { + id: 'export_metadata', + parentID: 'export', + active: true, + visible: true, + model: { + type: MenuItemType.ONCLICK, + text: 'menu.section.export_metadata', + function: () => { + this.modalService.open(ExportMetadataSelectorComponent); + } + } as OnClickMenuItemModel, + shouldPersistOnRouteChange: true + }); + }); + } + + /** + * Create menu sections dependent on whether or not the current user is a site administrator and on whether or not + * the import scripts exist and the current user is allowed to execute them + */ + createImportMenuSections() { + const menuList = [ + /* Import */ + { + id: 'import', + active: false, + visible: true, + model: { + type: MenuItemType.TEXT, + text: 'menu.section.import' + } as TextMenuItemModel, + icon: 'file-import', + index: 2 + }, + // TODO: enable this menu item once the feature has been implemented + // { + // id: 'import_batch', + // parentID: 'import', + // active: false, + // visible: true, + // model: { + // type: MenuItemType.LINK, + // text: 'menu.section.import_batch', + // link: '' + // } as LinkMenuItemModel, + // } + ]; + menuList.forEach((menuSection) => this.menuService.addSection(MenuID.ADMIN, Object.assign(menuSection, { + shouldPersistOnRouteChange: true + }))); + + observableCombineLatest( + this.authorizationService.isAuthorized(FeatureID.AdministratorOf), + // this.scriptDataService.scriptWithNameExistsAndCanExecute(METADATA_IMPORT_SCRIPT_NAME) + ).pipe( + // TODO uncomment when #635 (https://github.com/DSpace/dspace-angular/issues/635) is fixed + // filter(([authorized, metadataImportScriptExists]: boolean[]) => authorized && metadataImportScriptExists), + take(1) + ).subscribe(() => { + this.menuService.addSection(MenuID.ADMIN, { + id: 'import_metadata', + parentID: 'import', + active: true, + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.import_metadata', + link: '/admin/metadata-import' + } as LinkMenuItemModel, + shouldPersistOnRouteChange: true + }); + }); + } + + /** + * Create menu sections dependent on whether or not the current user is a site administrator + */ + createSiteAdministratorMenuSections() { + this.authorizationService.isAuthorized(FeatureID.AdministratorOf).subscribe((authorized) => { + const menuList = [ + /* Admin Search */ + { + id: 'admin_search', + active: false, + visible: authorized, + model: { + type: MenuItemType.LINK, + text: 'menu.section.admin_search', + link: '/admin/search' + } as LinkMenuItemModel, + icon: 'search', + index: 5 + }, + /* Registries */ + { + id: 'registries', + active: false, + visible: authorized, + model: { + type: MenuItemType.TEXT, + text: 'menu.section.registries' + } as TextMenuItemModel, + icon: 'list', + index: 6 + }, + { + id: 'registries_metadata', + parentID: 'registries', + active: false, + visible: authorized, + model: { + type: MenuItemType.LINK, + text: 'menu.section.registries_metadata', + link: 'admin/registries/metadata' + } as LinkMenuItemModel, + }, + { + id: 'registries_format', + parentID: 'registries', + active: false, + visible: authorized, + model: { + type: MenuItemType.LINK, + text: 'menu.section.registries_format', + link: 'admin/registries/bitstream-formats' + } as LinkMenuItemModel, + }, + + /* Curation tasks */ + { + id: 'curation_tasks', + active: false, + visible: authorized, + model: { + type: MenuItemType.LINK, + text: 'menu.section.curation_task', + link: 'admin/curation-tasks' + } as LinkMenuItemModel, + icon: 'filter', + index: 7 + }, + + /* Workflow */ + { + id: 'workflow', + active: false, + visible: authorized, + model: { + type: MenuItemType.LINK, + text: 'menu.section.workflow', + link: '/admin/workflow' + } as LinkMenuItemModel, + icon: 'user-check', + index: 11 + }, + ]; + + menuList.forEach((menuSection) => this.menuService.addSection(MenuID.ADMIN, Object.assign(menuSection, { + shouldPersistOnRouteChange: true + }))); + }); + } + + /** + * Create menu sections dependent on whether or not the current user can manage access control groups + */ + createAccessControlMenuSections() { + observableCombineLatest( + this.authorizationService.isAuthorized(FeatureID.AdministratorOf), + this.authorizationService.isAuthorized(FeatureID.CanManageGroups) + ).subscribe(([isSiteAdmin, canManageGroups]) => { + const menuList = [ + /* Access Control */ + { + id: 'access_control_people', + parentID: 'access_control', + active: false, + visible: isSiteAdmin, + model: { + type: MenuItemType.LINK, + text: 'menu.section.access_control_people', + link: '/access-control/epeople' + } as LinkMenuItemModel, + }, + { + id: 'access_control_groups', + parentID: 'access_control', + active: false, + visible: canManageGroups, + model: { + type: MenuItemType.LINK, + text: 'menu.section.access_control_groups', + link: '/access-control/groups' + } as LinkMenuItemModel, + }, + // TODO: enable this menu item once the feature has been implemented + // { + // id: 'access_control_authorizations', + // parentID: 'access_control', + // active: false, + // visible: authorized, + // model: { + // type: MenuItemType.LINK, + // text: 'menu.section.access_control_authorizations', + // link: '' + // } as LinkMenuItemModel, + // }, + { + id: 'access_control', + active: false, + visible: canManageGroups || isSiteAdmin, + model: { + type: MenuItemType.TEXT, + text: 'menu.section.access_control' + } as TextMenuItemModel, + icon: 'key', + index: 4 + }, + ]; + + menuList.forEach((menuSection) => this.menuService.addSection(MenuID.ADMIN, Object.assign(menuSection, { + shouldPersistOnRouteChange: true, + }))); + }); } } diff --git a/src/app/shared/testing/menu-service.stub.ts b/src/app/shared/testing/menu-service.stub.ts index 4f2828a7e1..ed1098955b 100644 --- a/src/app/shared/testing/menu-service.stub.ts +++ b/src/app/shared/testing/menu-service.stub.ts @@ -1,6 +1,6 @@ import { MenuID } from '../menu/initial-menus-state'; import { Observable, of as observableOf } from 'rxjs'; -import { MenuSection } from '../menu/menu.reducer'; +import { MenuSection, MenuState } from '../menu/menu.reducer'; export class MenuServiceStub { visibleSection1 = { @@ -77,6 +77,10 @@ export class MenuServiceStub { return observableOf(true); } + getMenu(id: MenuID): Observable { + return observableOf({} as MenuState); + } + getMenuTopSections(id: MenuID): Observable { return observableOf([this.visibleSection1, this.visibleSection2]); }